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}
938
939#[derive(Debug, Clone, PartialEq)]
940pub enum MoveOptionKind {
941 Move {
942 location: flatland_protocol::InventoryLocation,
943 parent_instance_id: Option<uuid::Uuid>,
944 },
945 PickupPlaced {
947 container_id: String,
948 nest_location: flatland_protocol::InventoryLocation,
949 nest_parent_instance_id: Option<uuid::Uuid>,
950 },
951 RelocatePlaced {
953 container_id: String,
954 },
955 Use,
957 GrantApply,
959 Drop,
960 SellPlotToCrown {
962 plot_id: uuid::Uuid,
963 },
964 Cancel,
965}
966
967#[derive(Debug, Clone, PartialEq)]
969pub enum FarmAccessRow {
970 PublicToggle,
971 PublicDiscount,
972 AllowRemove {
973 character_id: uuid::Uuid,
974 label: String,
975 tax_discount_bps: u32,
976 },
977 NearbyAdd {
978 name: String,
979 },
980}
981
982#[derive(Debug, Clone)]
984pub struct GrantTargetPicker {
985 pub grant_instance_id: uuid::Uuid,
986 pub grant_label: String,
987 pub effect_id: String,
988 pub mode: String,
989 pub options: Vec<GrantTargetOption>,
990 pub filter: String,
991 pub filter_focused: bool,
992}
993
994#[derive(Debug, Clone)]
995pub struct GrantTargetOption {
996 pub label: String,
997 pub target_instance_id: uuid::Uuid,
998}
999
1000#[derive(Debug, Clone)]
1002pub struct MovePicker {
1003 pub item_instance_id: uuid::Uuid,
1004 pub from: flatland_protocol::InventoryLocation,
1005 pub item_label: String,
1006 pub template_id: String,
1007 pub stack_quantity: u32,
1008 pub quantity: u32,
1009 pub options: Vec<MoveOption>,
1010 pub filter: String,
1011 pub filter_focused: bool,
1012}
1013
1014#[derive(Debug, Clone)]
1016pub struct DestroyPicker {
1017 pub item_instance_id: uuid::Uuid,
1018 pub from: flatland_protocol::InventoryLocation,
1019 pub item_label: String,
1020 pub stack_quantity: u32,
1021 pub quantity: u32,
1022}
1023
1024#[derive(Debug, Clone)]
1026pub struct WorkerGiveOption {
1027 pub item_instance_id: uuid::Uuid,
1028 pub label: String,
1029 pub quantity: u32,
1030 pub template_id: String,
1031}
1032
1033#[derive(Debug, Clone)]
1035pub struct WorkerGivePicker {
1036 pub worker_instance_id: String,
1037 pub worker_label: String,
1038 pub options: Vec<WorkerGiveOption>,
1039}
1040
1041#[derive(Debug, Clone)]
1043pub struct WorkerGiveTargetOption {
1044 pub instance_id: String,
1045 pub label: String,
1046 pub distance_m: f32,
1047}
1048
1049#[derive(Debug, Clone)]
1051pub struct WorkerGiveTargetPicker {
1052 pub item_instance_id: uuid::Uuid,
1053 pub item_label: String,
1054 pub quantity: Option<u32>,
1055 pub options: Vec<WorkerGiveTargetOption>,
1056}
1057
1058#[derive(Debug, Clone)]
1060pub struct WorkerTakePicker {
1061 pub worker_instance_id: String,
1062 pub worker_label: String,
1063 pub options: Vec<WorkerGiveOption>,
1064 pub quantity: u32,
1066}
1067
1068pub const WORKER_GIVE_RANGE_M: f32 = 4.0;
1070
1071#[derive(Debug, Clone)]
1073pub struct WorkerTeachOption {
1074 pub blueprint_id: String,
1075 pub label: String,
1076 pub cost_copper: u64,
1077 pub min_level: u32,
1078 pub worker_level: u32,
1079 pub can_afford: bool,
1080 pub level_ok: bool,
1081}
1082
1083#[derive(Debug, Clone)]
1085pub struct WorkerTeachPicker {
1086 pub worker_instance_id: String,
1087 pub worker_label: String,
1088 pub worker_level: u32,
1089 pub options: Vec<WorkerTeachOption>,
1090}
1091
1092#[derive(Debug, Clone)]
1094pub struct WorkerDismissConfirmation {
1095 pub worker_instance_id: String,
1096 pub worker_label: String,
1097}
1098
1099#[derive(Debug, Clone, Default)]
1102pub struct StickyWorkerStep {
1103 shown: String,
1104 pending: String,
1105 pending_since: Option<Instant>,
1106}
1107
1108impl StickyWorkerStep {
1109 fn from_label(label: String) -> Self {
1110 Self {
1111 shown: label.clone(),
1112 pending: label,
1113 pending_since: Some(Instant::now()),
1114 }
1115 }
1116
1117 fn observe(&mut self, label: &str, now: Instant) {
1118 let pending_since = self.pending_since.unwrap_or(now);
1119 if label == self.pending {
1120 if self.shown != self.pending && now.duration_since(pending_since) >= WORKER_STEP_HOLD {
1121 self.shown = self.pending.clone();
1122 }
1123 return;
1124 }
1125 self.pending = label.to_string();
1126 self.pending_since = Some(now);
1127 if self.shown.is_empty() {
1129 self.shown = self.pending.clone();
1130 }
1131 }
1132}
1133
1134#[derive(Debug, Clone, Default)]
1137pub struct StickyWorkerError {
1138 message: String,
1139 last_seen: Option<Instant>,
1140}
1141
1142impl StickyWorkerError {
1143 fn observe(&mut self, err: Option<&str>, now: Instant) {
1144 if let Some(e) = err {
1145 if !worker_error_is_transient(e) && !worker_error_is_hud_noise(e) {
1146 self.message = e.to_string();
1147 self.last_seen = Some(now);
1148 }
1149 return;
1150 }
1151 if let Some(seen) = self.last_seen {
1152 if now.duration_since(seen) > WORKER_ERROR_HOLD {
1153 self.message.clear();
1154 self.last_seen = None;
1155 }
1156 }
1157 }
1158
1159 pub fn shown(&self, now: Instant) -> Option<&str> {
1160 if self.message.is_empty() {
1161 return None;
1162 }
1163 let seen = self.last_seen?;
1164 if now.duration_since(seen) > WORKER_ERROR_HOLD {
1165 return None;
1166 }
1167 Some(self.message.as_str())
1168 }
1169}
1170
1171pub fn worker_attention_line(state: &GameState) -> Option<String> {
1174 use flatland_protocol::WorkerStateView;
1175 let now = Instant::now();
1176 for w in &state.hired_workers {
1177 if matches!(w.state, WorkerStateView::Strike) {
1178 return Some(format!(
1179 "Worker {}: on strike — fund bank, pay wages, or stock lodging chest",
1180 w.label
1181 ));
1182 }
1183 let sticky = state
1184 .worker_error_display
1185 .get(&w.instance_id)
1186 .and_then(|s| s.shown(now))
1187 .filter(|e| !worker_error_is_hud_noise(e));
1188 let live = w
1189 .last_error
1190 .as_deref()
1191 .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e));
1192 if let Some(err) = sticky.or(live) {
1193 if let Some(hint) = w
1194 .issue_hint
1195 .as_deref()
1196 .filter(|h| !h.is_empty())
1197 .or_else(|| worker_issue_fix_hint(err))
1198 {
1199 return Some(format!("Worker {}: {err} — {hint}", w.label));
1200 }
1201 return Some(format!("Worker {}: {err}", w.label));
1202 }
1203 if let Some(hint) = w.issue_hint.as_deref().filter(|h| !h.is_empty()) {
1205 return Some(format!("Worker {}: {hint}", w.label));
1206 }
1207 }
1208 None
1209}
1210
1211pub fn worker_issue_fix_hint(err: &str) -> Option<&'static str> {
1213 let e = err.to_ascii_lowercase();
1214 if e.contains("missing")
1215 || e.contains("container not found")
1216 || e.contains("lodging container not found")
1217 {
1218 return Some("edit route (e): replace the missing chest/bed");
1219 }
1220 if e.contains("stranded at interior") || e.contains("interior map coords") {
1221 return Some("recovered — continuing route");
1222 }
1223 if e.contains("stuck inside")
1224 || e.contains("sent outside")
1225 || e.contains("sent to door")
1226 || e.contains("left building")
1227 {
1228 return Some("auto-exit for outdoor work — restart after update if it still loops");
1229 }
1230 if e.contains("collapsed") || e.contains("need food") {
1231 return Some("stock lodging bed with food and drink");
1232 }
1233 if e.contains("overburdened") {
1234 return Some("add a deposit/sell stop, or empty their pack");
1235 }
1236 if e.contains("storage full") {
1237 return Some("empty or upgrade the destination chest, or reassign the deposit");
1238 }
1239 if e.contains("need a hoe") || e.contains("need a dibber") {
1240 return Some("give them the tool or withdraw it on the route");
1241 }
1242 None
1243}
1244
1245pub fn worker_error_is_transient(err: &str) -> bool {
1247 let e = err.to_ascii_lowercase();
1248 e.contains("continuing route") || e.starts_with("nothing to withdraw")
1249}
1250
1251pub fn worker_error_is_hud_noise(err: &str) -> bool {
1254 let e = err.to_ascii_lowercase();
1255 if e.contains("idling") && (e.contains("cannot reach") || e.contains("unreachable")) {
1257 return false;
1258 }
1259 e.contains("returned to lodging after path")
1260 || e.contains("path failure")
1261 || e.contains("no path to")
1262 || e.contains("pathfinding")
1263 || e.contains("repathing")
1265 || e.contains("nudged clear")
1266 || e.contains("auto-recovery")
1268 || e.contains("stranded at interior map coords")
1269}
1270
1271#[derive(Debug, Clone)]
1273pub struct PendingWorkerJobAck {
1274 pub seq: u32,
1275 pub worker_instance_id: String,
1276 pub worker_label: String,
1277 pub idle: bool,
1278 pub stop_count: usize,
1279 pub prev_route: Option<flatland_protocol::WorkerRouteView>,
1280 pub prev_mode: flatland_protocol::WorkerModeView,
1281 pub prev_step_label: String,
1282 pub prev_last_error: Option<String>,
1283}
1284
1285fn push_inventory_rows(
1286 rows: &mut Vec<InventoryRow>,
1287 depth: usize,
1288 stack: &flatland_protocol::ItemStack,
1289 from: &flatland_protocol::InventoryLocation,
1290 from_parent_instance_id: Option<uuid::Uuid>,
1291 section: InventorySection,
1292) {
1293 push_inventory_rows_filtered(
1294 rows,
1295 depth,
1296 stack,
1297 from,
1298 from_parent_instance_id,
1299 section,
1300 "",
1301 );
1302}
1303
1304fn stack_matches_filter(stack: &flatland_protocol::ItemStack, filter: &str) -> bool {
1305 if filter.is_empty() {
1306 return true;
1307 }
1308 let f = filter.to_ascii_lowercase();
1309 let name = stack
1310 .display_name
1311 .as_deref()
1312 .unwrap_or("")
1313 .to_ascii_lowercase();
1314 let tid = stack.template_id.to_ascii_lowercase();
1315 name.contains(&f)
1316 || tid.contains(&f)
1317 || stack
1318 .contents
1319 .iter()
1320 .any(|c| stack_matches_filter(c, filter))
1321}
1322
1323fn push_inventory_rows_filtered(
1324 rows: &mut Vec<InventoryRow>,
1325 depth: usize,
1326 stack: &flatland_protocol::ItemStack,
1327 from: &flatland_protocol::InventoryLocation,
1328 from_parent_instance_id: Option<uuid::Uuid>,
1329 section: InventorySection,
1330 filter: &str,
1331) {
1332 if !filter.is_empty() && !stack_matches_filter(stack, filter) {
1333 return;
1334 }
1335 let self_hit = filter.is_empty() || {
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) || tid.contains(&f)
1344 };
1345 rows.push(InventoryRow {
1346 depth,
1347 stack: stack.clone(),
1348 from: from.clone(),
1349 from_parent_instance_id,
1350 is_equip_shell: false,
1351 is_chest_shell: false,
1352 section,
1353 });
1354 for child in &stack.contents {
1355 if self_hit || filter.is_empty() || stack_matches_filter(child, filter) {
1356 push_inventory_rows_filtered(
1357 rows,
1358 depth + 1,
1359 child,
1360 from,
1361 stack.item_instance_id,
1362 section,
1363 if self_hit { "" } else { filter },
1364 );
1365 }
1366 }
1367}
1368
1369#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1370pub enum ShopTab {
1371 #[default]
1372 Buy,
1373 Sell,
1374}
1375
1376#[derive(Debug, Clone)]
1377pub struct NpcChatState {
1378 pub npc_id: String,
1379 pub npc_label: String,
1380 pub lines: Vec<String>,
1381 pub input: String,
1382 pub pending: bool,
1383 pub talk_depth: flatland_protocol::NpcTalkDepth,
1384 pub trade_allowed: bool,
1385 pub banner: Option<String>,
1386 pub suggested_topics: Vec<String>,
1387}
1388
1389impl Default for NpcChatState {
1390 fn default() -> Self {
1391 Self {
1392 npc_id: String::new(),
1393 npc_label: String::new(),
1394 lines: Vec::new(),
1395 input: String::new(),
1396 pending: false,
1397 talk_depth: flatland_protocol::NpcTalkDepth::Full,
1398 trade_allowed: true,
1399 banner: None,
1400 suggested_topics: Vec::new(),
1401 }
1402 }
1403}
1404
1405pub fn npc_world_xy(state: &GameState, npc: &NpcView) -> (f32, f32) {
1407 npc.entity_id
1408 .and_then(|eid| state.entities.iter().find(|e| e.id == eid))
1409 .map(|e| (e.transform.position.x, e.transform.position.y))
1410 .unwrap_or((npc.x, npc.y))
1411}
1412
1413#[derive(Debug, Clone)]
1414pub struct GameState {
1415 pub session_id: SessionId,
1416 pub entity_id: EntityId,
1417 pub character_id: Option<uuid::Uuid>,
1419 pub tick: Tick,
1420 pub chunk_rev: u64,
1421 pub content_rev: u64,
1422 pub publish_rev: u64,
1423 pub entities: Vec<EntityState>,
1424 pub player: Option<EntityState>,
1425 pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
1426 pub harvest_route_nodes: Vec<flatland_protocol::ResourceNodeView>,
1428 pub ground_drops: Vec<flatland_protocol::GroundDropView>,
1429 pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
1430 pub buildings: Vec<BuildingView>,
1431 pub doors: Vec<DoorView>,
1432 pub interior_map: Option<InteriorMapView>,
1433 pub npcs: Vec<NpcView>,
1434 pub blueprints: Vec<BlueprintView>,
1435 pub building_materials: Vec<flatland_protocol::BuildingMaterialView>,
1437 pub world_x0: f32,
1439 pub world_y0: f32,
1440 pub world_width_m: f32,
1441 pub world_height_m: f32,
1442 pub terrain_zones: Vec<TerrainZoneView>,
1443 pub z_platforms: Vec<ZPlatformView>,
1444 pub z_transitions: Vec<ZTransitionView>,
1445 #[doc(hidden)]
1448 pub z_bands_outdoor_backup: Option<(Vec<ZPlatformView>, Vec<ZTransitionView>)>,
1449 pub world_clock: flatland_protocol::WorldClock,
1450 pub inventory: std::collections::HashMap<String, u32>,
1451 pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1452 pub item_catalog: std::collections::HashMap<String, ItemCatalogEntryView>,
1454 pub logs: VecDeque<String>,
1455 pub intents_sent: u64,
1456 pub ticks_received: u64,
1457 pub connected: bool,
1458 pub disconnect_reason: Option<String>,
1459 pub show_stats: bool,
1460 pub hud_log_hidden: bool,
1462 pub show_equip_menu: bool,
1463 pub equip_menu_index: usize,
1464 pub show_craft_menu: bool,
1465 pub craft_menu_index: usize,
1466 pub craft_batch_quantity: u32,
1468 pub craft_tab: CraftTab,
1470 pub craft_filter: String,
1472 pub craft_filter_focused: bool,
1473 pub craft_prefs: crate::craft_prefs::CraftCharacterPrefs,
1475 pub show_plot_build_menu: bool,
1477 pub plot_build_focus_wall: bool,
1479 pub plot_build_wall_index: usize,
1480 pub plot_build_roof_index: usize,
1481 pub show_shop_menu: bool,
1482 pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1483 pub bank_panel: Option<flatland_protocol::BankPanel>,
1484 pub bank_menu_index: usize,
1485 pub bank_ui_mode: BankUiMode,
1486 pub storage_panel: Option<flatland_protocol::StoragePanel>,
1487 pub market_panel: Option<flatland_protocol::MarketPanel>,
1488 pub market_menu_index: usize,
1490 pub market_filter: String,
1492 pub market_filter_focused: bool,
1493 pub market_category_filter: Option<&'static str>,
1495 pub market_buy_confirm: Option<(uuid::Uuid, u32, u64, u64, String)>,
1497 pub market_ui_mode: MarketUiMode,
1498 pub storage_menu_index: usize,
1499 pub storage_ui_mode: StorageUiMode,
1500 pub shop_tab: ShopTab,
1501 pub shop_menu_index: usize,
1502 pub shop_quantity: u32,
1503 pub shop_trade_log: VecDeque<String>,
1505 pub show_npc_verb_menu: bool,
1506 pub npc_verb_target: Option<String>,
1507 pub npc_verb_index: usize,
1508 pub npc_verb_notice: Option<String>,
1510 pub player_verbs: crate::social::PlayerVerbState,
1512 pub social_chat: crate::social::SocialChatState,
1513 pub trade_ui: crate::social::TradeUiState,
1514 pub whisper_pouch_ui: crate::social::WhisperPouchUi,
1515 pub show_npc_chat: bool,
1516 pub npc_chat: Option<NpcChatState>,
1517 pub show_inventory_menu: bool,
1518 pub inventory_menu_index: usize,
1519 pub inventory_tab: InventoryTab,
1520 pub inventory_filter: String,
1521 pub inventory_filter_focused: bool,
1522 pub show_move_picker: bool,
1523 pub move_picker_index: usize,
1524 pub move_picker: Option<MovePicker>,
1525 pub show_grant_picker: bool,
1526 pub grant_picker_index: usize,
1527 pub grant_picker: Option<GrantTargetPicker>,
1528 pub show_destroy_picker: bool,
1529 pub destroy_confirm_pending: bool,
1530 pub destroy_picker: Option<DestroyPicker>,
1531 pub show_rename_prompt: bool,
1533 pub rename_plot_id: Option<uuid::Uuid>,
1535 pub highlighted_plot_id: Option<uuid::Uuid>,
1537 pub show_worker_rename: bool,
1539 pub rename_buffer: String,
1540 pub combat_target: Option<EntityId>,
1542 pub combat_target_label: Option<String>,
1543 pub ground_target: Option<(f32, f32, f32)>,
1546 pub combat_fx: Vec<flatland_protocol::CombatFx>,
1548 pub ground_hazards: Vec<flatland_protocol::GroundHazardView>,
1550 pub property_zones: Vec<flatland_protocol::PropertyZoneView>,
1552 pub tax_zones: Vec<flatland_protocol::TaxZoneView>,
1554 pub growth_zones: Vec<flatland_protocol::GrowthZoneView>,
1556 pub biome_zones: Vec<flatland_protocol::BiomeZoneView>,
1558 pub terrain_kind_nav: Vec<flatland_protocol::TerrainKindNavView>,
1560 pub property_plots: Vec<flatland_protocol::PropertyPlotView>,
1562 pub property_plot_settings: Option<flatland_protocol::PropertyPlotSettingsView>,
1564 pub claim_mode: Option<ClaimModeState>,
1566 pub relocate_mode: Option<RelocateModeState>,
1568 pub sell_plot_confirm: Option<uuid::Uuid>,
1570 pub sell_plot_armed_at: Option<Instant>,
1572 pub show_plant_menu: bool,
1574 pub plant_menu_index: usize,
1575 pub show_farm_access: bool,
1577 pub farm_access_name_draft: String,
1579 pub farm_access_discount_bps: u32,
1581 pub farm_access_index: usize,
1583 pub plant_quantity: u32,
1584 pub in_combat: bool,
1585 pub auto_attack: bool,
1586 pub combat_has_los: bool,
1587 pub attack_cd_ticks: u64,
1588 pub gcd_ticks: u64,
1589 pub weapon_ability_id: String,
1590 pub mainhand_template_id: Option<String>,
1591 pub mainhand_label: Option<String>,
1592 pub mainhand_instance_id: Option<uuid::Uuid>,
1593 pub offhand_template_id: Option<String>,
1594 pub offhand_label: Option<String>,
1595 pub offhand_instance_id: Option<uuid::Uuid>,
1596 pub mainhand_hand_slots: u8,
1597 pub defense: Option<flatland_protocol::DefenseHud>,
1598 pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1600 pub carry_mass: f32,
1601 pub carry_mass_max: f32,
1602 pub encumbrance: flatland_protocol::EncumbranceState,
1603 pub move_speed_mps: f32,
1605 pub move_speed_mult: f32,
1607 pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1609 pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1611 pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
1613 pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1615 pub combat_target_detail: Option<CombatTargetHud>,
1616 pub cast_progress: Option<CastProgressHud>,
1617 pub timed_channel: Option<flatland_protocol::TimedChannelHud>,
1619 pub plot_build_offer: Option<flatland_protocol::PlotBuildOfferHud>,
1621 pub ability_cooldowns: Vec<AbilityCooldownHud>,
1622 pub blocking_active: bool,
1623 pub max_target_slots: u8,
1624 pub combat_slots: Vec<CombatSlotHud>,
1625 pub rotation_presets: Vec<RotationPreset>,
1626 pub known_abilities: Vec<String>,
1628 pub ability_meta: std::collections::HashMap<String, flatland_protocol::AbilityMetaHud>,
1630 pub ability_mastery: std::collections::HashMap<String, flatland_protocol::AbilityMasteryHud>,
1632 pub hotbar: Vec<Option<String>>,
1634 pub max_abilities_per_rotation: u8,
1636 pub show_loadout_menu: bool,
1637 pub show_keychain_menu: bool,
1638 pub keychain_menu_index: usize,
1639 pub show_rotation_editor: bool,
1640 pub loadout_menu_index: usize,
1642 pub loadout_hotbar_slot: u8,
1644 pub loadout_ability_index: usize,
1646 pub loadout_focus_presets: bool,
1648 pub rotation_editor: RotationEditorState,
1649 pub harvest_in_progress: bool,
1651 pub harvest_started_at: Option<Instant>,
1653 pub pending_craft_ack: Option<(u32, String, u32)>,
1655 pub craft_channel_blueprint_id: Option<String>,
1658 pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1659 pub interactables: Vec<flatland_protocol::InteractableView>,
1660 pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1661 pub career: Option<flatland_protocol::PlayerCareerView>,
1662 pub character_sheet_tab: CharacterSheetTab,
1663 pub ledger_period: LedgerPeriod,
1664 pub show_quest_offer: bool,
1665 pub pending_quest_offers: Vec<flatland_protocol::QuestOffer>,
1666 pub quest_offer_index: usize,
1667 pub show_quest_menu: bool,
1668 pub quest_menu_index: usize,
1669 pub quest_withdraw_confirm: bool,
1670 pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1671 pub show_workers_menu: bool,
1672 pub workers_menu_index: usize,
1673 pub worker_dismiss_confirmation: Option<WorkerDismissConfirmation>,
1674 pub workers_menu_compact: bool,
1676 pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1679 pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1681 pub worker_health_ring_until: BTreeMap<EntityId, Instant>,
1683 pub pending_worker_hire_since: Option<Instant>,
1685 pub show_worker_give_picker: bool,
1687 pub worker_give_picker_index: usize,
1688 pub worker_give_picker: Option<WorkerGivePicker>,
1689 pub show_worker_give_target_picker: bool,
1691 pub worker_give_target_picker_index: usize,
1692 pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1693 pub show_worker_take_picker: bool,
1695 pub worker_take_picker_index: usize,
1696 pub worker_take_picker: Option<WorkerTakePicker>,
1697 pub show_worker_teach_picker: bool,
1699 pub worker_teach_picker_index: usize,
1700 pub worker_teach_picker: Option<WorkerTeachPicker>,
1701 pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1703 pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1705 pub attending_worker_instance_id: Option<String>,
1707 pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1709}
1710
1711#[derive(Debug, Clone, PartialEq, Eq)]
1712pub enum NpcVerbAction {
1713 Talk,
1714 Trade,
1715 Bank,
1716 Storage,
1717 Market,
1718 QuestTalk { quest_id: String },
1719 QuestGive { quest_id: String },
1720}
1721
1722#[derive(Debug, Clone, PartialEq, Eq)]
1723pub struct NpcVerbChoice {
1724 pub label: String,
1725 pub action: NpcVerbAction,
1726}
1727
1728impl std::fmt::Display for NpcVerbChoice {
1729 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1730 f.write_str(&self.label)
1731 }
1732}
1733
1734impl GameState {
1735 pub fn selected_quest_offer(&self) -> Option<&flatland_protocol::QuestOffer> {
1736 self.pending_quest_offers.get(self.quest_offer_index)
1737 }
1738
1739 pub fn push_quest_offer(&mut self, offer: flatland_protocol::QuestOffer) {
1740 if self
1741 .pending_quest_offers
1742 .iter()
1743 .any(|existing| existing.quest_id == offer.quest_id)
1744 {
1745 self.show_quest_offer = true;
1746 return;
1747 }
1748 self.pending_quest_offers.push(offer);
1749 self.show_quest_offer = true;
1750 }
1751
1752 pub fn remove_quest_offer(&mut self, quest_id: &str) {
1753 self.pending_quest_offers
1754 .retain(|offer| offer.quest_id != quest_id);
1755 if self.pending_quest_offers.is_empty() {
1756 self.show_quest_offer = false;
1757 self.quest_offer_index = 0;
1758 return;
1759 }
1760 self.quest_offer_index = self
1761 .quest_offer_index
1762 .min(self.pending_quest_offers.len() - 1);
1763 self.show_quest_offer = true;
1764 }
1765
1766 pub fn clear_quest_offers(&mut self) {
1767 self.pending_quest_offers.clear();
1768 self.quest_offer_index = 0;
1769 self.show_quest_offer = false;
1770 }
1771
1772 pub fn move_quest_offer_selection(&mut self, delta: i32) {
1773 let n = self.pending_quest_offers.len();
1774 if n == 0 {
1775 self.quest_offer_index = 0;
1776 return;
1777 }
1778 let idx = self.quest_offer_index as i32;
1779 self.quest_offer_index = (idx + delta).rem_euclid(n as i32) as usize;
1780 }
1781
1782 pub fn push_log(&mut self, line: impl Into<String>) {
1783 self.logs.push_back(line.into());
1784 while self.logs.len() > MAX_LOG_LINES {
1785 self.logs.pop_front();
1786 }
1787 }
1788
1789 pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1790 self.shop_trade_log.push_back(line.into());
1791 while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1792 self.shop_trade_log.pop_front();
1793 }
1794 }
1795
1796 pub fn clear_shop_trade_log(&mut self) {
1797 self.shop_trade_log.clear();
1798 }
1799
1800 fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1801 if !self.show_shop_menu {
1802 return;
1803 }
1804 let msg = notice.message.trim();
1805 if msg.is_empty() {
1806 return;
1807 }
1808 if notice.coins_delta != 0
1809 || msg.starts_with("Bought ")
1810 || msg.starts_with("Sold ")
1811 || msg.contains("taught you how to craft")
1812 || msg.starts_with("need ")
1813 {
1814 self.push_shop_trade_log(msg);
1815 }
1816 }
1817
1818 pub fn is_alive(&self) -> bool {
1819 self.player
1820 .as_ref()
1821 .and_then(|p| p.vitals)
1822 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1823 .unwrap_or(true)
1824 }
1825
1826 pub fn push_audio(&mut self, cue: crate::social::AudioCue) {
1827 self.social_chat.push_cue(cue);
1828 }
1829
1830 fn sync_gameplay_audio(&mut self) {
1832 use crate::social::AudioCue;
1833 use flatland_protocol::PrimaryAttributes;
1834
1835 let alive = self.is_alive();
1836 let casting = self.cast_progress.is_some();
1837 let telegraph = self.focus_attack_telegraph_active();
1838 let in_aoe = self.player_inside_spatial_telegraph();
1839 let quest_sig = self.quest_audio_signature();
1840 let entity_id = self.entity_id;
1841 let char_level = self
1842 .player
1843 .as_ref()
1844 .and_then(|p| p.attributes)
1845 .map(|a| {
1846 PrimaryAttributes::display(a.strength)
1847 .saturating_add(PrimaryAttributes::display(a.dexterity))
1848 .saturating_add(PrimaryAttributes::display(a.intelligence))
1849 .saturating_add(PrimaryAttributes::display(a.stamina))
1850 .saturating_add(PrimaryAttributes::display(a.vitality))
1851 .saturating_add(PrimaryAttributes::display(a.wisdom))
1852 .saturating_add(PrimaryAttributes::display(a.charisma))
1853 })
1854 .unwrap_or(0);
1855
1856 let fx_ids: Vec<u64> = self.combat_fx.iter().map(|fx| fx.id).collect();
1857 let mut hit_cues = Vec::new();
1858 {
1859 let seen = &self.social_chat.audio_seen_fx_ids;
1860 for fx in &self.combat_fx {
1861 if seen.contains(&fx.id) {
1862 continue;
1863 }
1864 let Some(hit) = fx.hits.iter().find(|h| h.entity_id == entity_id) else {
1865 continue;
1866 };
1867 if hit.outcome == CombatFxHitOutcome::Blocked {
1868 hit_cues.push(AudioCue::CombatBlock);
1869 } else {
1870 let heavy = matches!(
1871 fx.kind,
1872 CombatFxKind::Sphere | CombatFxKind::Cone | CombatFxKind::Beam
1873 );
1874 hit_cues.push(if heavy {
1875 AudioCue::CombatHitHeavy
1876 } else {
1877 AudioCue::CombatHitLight
1878 });
1879 }
1880 }
1881 }
1882
1883 let audio = &mut self.social_chat;
1884 if !audio.audio_bootstrapped {
1885 audio.audio_was_alive = alive;
1886 audio.audio_was_casting = casting;
1887 audio.audio_had_target_telegraph = telegraph;
1888 audio.audio_was_in_aoe = in_aoe;
1889 audio.audio_quest_sig = quest_sig;
1890 audio.audio_char_level = char_level;
1891 audio.audio_seen_fx_ids = fx_ids;
1892 audio.audio_bootstrapped = true;
1893 return;
1894 }
1895
1896 if telegraph && !audio.audio_had_target_telegraph {
1897 audio.push_cue(AudioCue::CombatTelegraphStart);
1898 } else if !telegraph && audio.audio_had_target_telegraph {
1899 audio.push_cue(AudioCue::CombatTelegraphImpact);
1900 }
1901 audio.audio_had_target_telegraph = telegraph;
1902
1903 if in_aoe && !audio.audio_was_in_aoe {
1904 audio.push_cue(AudioCue::CombatAoeWarn);
1905 }
1906 audio.audio_was_in_aoe = in_aoe;
1907
1908 if casting && !audio.audio_was_casting {
1909 audio.push_cue(AudioCue::AbilityCastSelf);
1910 }
1911 audio.audio_was_casting = casting;
1912
1913 if !alive && audio.audio_was_alive {
1914 audio.push_cue(AudioCue::PlayerDeath);
1915 }
1916 audio.audio_was_alive = alive;
1917
1918 if quest_sig != audio.audio_quest_sig && audio.audio_quest_sig != 0 {
1919 audio.push_cue(AudioCue::QuestUpdate);
1920 }
1921 audio.audio_quest_sig = quest_sig;
1922
1923 if char_level > audio.audio_char_level && audio.audio_char_level > 0 {
1924 audio.push_cue(AudioCue::LevelUp);
1925 }
1926 audio.audio_char_level = char_level;
1927
1928 for cue in hit_cues {
1929 audio.push_cue(cue);
1930 }
1931 audio.audio_seen_fx_ids = fx_ids;
1932 }
1933
1934 fn focus_attack_telegraph_active(&self) -> bool {
1935 let Some(tid) = self.combat_target else {
1936 return false;
1937 };
1938 self.entities
1939 .iter()
1940 .find(|e| e.id == tid)
1941 .map(|e| {
1942 e.combat_cues.iter().any(|c| {
1943 matches!(c.kind, CombatCueKind::AttackTelegraph) && c.until_tick > self.tick
1944 })
1945 })
1946 .unwrap_or(false)
1947 }
1948
1949 fn player_inside_spatial_telegraph(&self) -> bool {
1950 let (px, py) = self.player_position();
1951 for e in &self.entities {
1952 for cue in &e.combat_cues {
1953 if !matches!(cue.kind, CombatCueKind::AttackTelegraph)
1954 || cue.until_tick <= self.tick
1955 {
1956 continue;
1957 }
1958 let Some(kind) = cue.telegraph_kind else {
1959 continue;
1960 };
1961 let (ox, oy) = match (cue.origin_x, cue.origin_y) {
1962 (Some(x), Some(y)) => (x, y),
1963 _ => continue,
1964 };
1965 match kind {
1966 CombatFxKind::Sphere => {
1967 let r = cue.radius_m.unwrap_or(1.0);
1968 let dx = px - ox;
1969 let dy = py - oy;
1970 if dx * dx + dy * dy <= r * r {
1971 return true;
1972 }
1973 }
1974 CombatFxKind::Cone | CombatFxKind::MeleeArc => {
1975 let reach = cue.reach_m.unwrap_or(2.0);
1976 let yaw = cue.yaw.unwrap_or(0.0);
1977 let arc = cue.arc_deg.unwrap_or(90.0).to_radians();
1978 let dx = px - ox;
1979 let dy = py - oy;
1980 let dist = (dx * dx + dy * dy).sqrt();
1981 if dist > reach || dist < 0.05 {
1982 continue;
1983 }
1984 let ang = dx.atan2(dy);
1985 let mut delta = ang - yaw;
1986 while delta > std::f32::consts::PI {
1987 delta -= std::f32::consts::TAU;
1988 }
1989 while delta < -std::f32::consts::PI {
1990 delta += std::f32::consts::TAU;
1991 }
1992 if delta.abs() <= arc * 0.5 {
1993 return true;
1994 }
1995 }
1996 _ => {}
1997 }
1998 }
1999 }
2000 false
2001 }
2002
2003 fn quest_audio_signature(&self) -> u64 {
2004 use std::collections::hash_map::DefaultHasher;
2005 use std::hash::{Hash, Hasher};
2006 let mut h = DefaultHasher::new();
2007 for q in &self.quest_log {
2008 q.quest_id.hash(&mut h);
2009 format!("{:?}", q.status).hash(&mut h);
2010 q.current_step_id.hash(&mut h);
2011 for o in &q.objectives {
2012 o.done.hash(&mut h);
2013 o.current.hash(&mut h);
2014 }
2015 }
2016 h.finish()
2017 }
2018
2019 pub fn npc_verb_options(&self) -> Vec<NpcVerbChoice> {
2021 let Some(ref id) = self.npc_verb_target else {
2022 return vec![];
2023 };
2024 let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
2025 return self.with_quest_verbs(id, vec![Self::talk_choice()]);
2026 };
2027 let role = npc.role.as_str();
2028 let rest = if Self::npc_role_is_bank(role) {
2029 vec![
2030 NpcVerbChoice {
2031 label: "Bank".into(),
2032 action: NpcVerbAction::Bank,
2033 },
2034 Self::talk_choice(),
2035 ]
2036 } else if Self::npc_role_is_storage(role) {
2037 vec![
2038 NpcVerbChoice {
2039 label: "Storage".into(),
2040 action: NpcVerbAction::Storage,
2041 },
2042 Self::talk_choice(),
2043 ]
2044 } else if Self::npc_role_is_market(role) {
2045 vec![
2046 NpcVerbChoice {
2047 label: "Market".into(),
2048 action: NpcVerbAction::Market,
2049 },
2050 Self::talk_choice(),
2051 ]
2052 } else if npc.can_trade || Self::npc_role_can_trade(role) {
2053 vec![
2054 Self::talk_choice(),
2055 NpcVerbChoice {
2056 label: "Trade".into(),
2057 action: NpcVerbAction::Trade,
2058 },
2059 ]
2060 } else {
2061 vec![Self::talk_choice()]
2062 };
2063 self.with_quest_verbs(id, rest)
2064 }
2065
2066 fn talk_choice() -> NpcVerbChoice {
2067 NpcVerbChoice {
2068 label: "Talk".into(),
2069 action: NpcVerbAction::Talk,
2070 }
2071 }
2072
2073 fn with_quest_verbs(&self, npc_id: &str, rest: Vec<NpcVerbChoice>) -> Vec<NpcVerbChoice> {
2074 let mut opts = self.quest_verb_choices(npc_id);
2075 opts.extend(rest);
2076 opts
2077 }
2078
2079 fn quest_verb_choices(&self, npc_id: &str) -> Vec<NpcVerbChoice> {
2080 if let Some(npc) = self.npcs.iter().find(|n| n.id == npc_id) {
2081 if !npc.quest_verbs.is_empty() {
2082 return npc
2083 .quest_verbs
2084 .iter()
2085 .map(|v| {
2086 let action = if v.kind == flatland_protocol::NpcQuestVerb::KIND_GIVE {
2087 NpcVerbAction::QuestGive {
2088 quest_id: v.quest_id.clone(),
2089 }
2090 } else {
2091 NpcVerbAction::QuestTalk {
2092 quest_id: v.quest_id.clone(),
2093 }
2094 };
2095 NpcVerbChoice {
2096 label: v.label.clone(),
2097 action,
2098 }
2099 })
2100 .collect();
2101 }
2102 }
2103 let catalog_ref = self.npc_quest_catalog_ref(npc_id);
2104 let mut opts = Vec::new();
2105 for q in &self.quest_log {
2106 if q.status != flatland_protocol::QuestStatusView::Active {
2107 continue;
2108 }
2109 let title = if q.title.trim().is_empty() {
2110 "Quest".to_string()
2111 } else {
2112 q.title.clone()
2113 };
2114 for o in &q.objectives {
2115 if o.done || o.npc_ref.as_deref() != Some(catalog_ref.as_str()) {
2116 continue;
2117 }
2118 if o.kind == "give_item" {
2119 opts.push(NpcVerbChoice {
2120 label: format!("Turn in: {title}"),
2121 action: NpcVerbAction::QuestGive {
2122 quest_id: q.quest_id.clone(),
2123 },
2124 });
2125 } else if o.kind == "talk_npc" {
2126 opts.push(NpcVerbChoice {
2127 label: title.clone(),
2128 action: NpcVerbAction::QuestTalk {
2129 quest_id: q.quest_id.clone(),
2130 },
2131 });
2132 }
2133 }
2134 }
2135 opts
2136 }
2137
2138 fn npc_quest_catalog_ref(&self, npc_id: &str) -> String {
2139 self.npcs
2140 .iter()
2141 .find(|n| n.id == npc_id)
2142 .and_then(|n| n.paperdoll_ref.clone())
2143 .unwrap_or_else(|| npc_id.to_string())
2144 }
2145
2146 fn count_inventory_template(&self, template: &str) -> u32 {
2147 self.inventory_stacks
2148 .iter()
2149 .filter(|s| s.template_id == template)
2150 .map(|s| s.quantity)
2151 .sum()
2152 }
2153
2154 fn npc_role_can_trade(role: &str) -> bool {
2155 matches!(role, "broker" | "cook" | "farmer" | "merchant")
2156 }
2157
2158 fn npc_role_is_bank(role: &str) -> bool {
2159 role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
2160 }
2161
2162 fn npc_role_is_storage(role: &str) -> bool {
2163 role.eq_ignore_ascii_case("storage_manager")
2164 }
2165
2166 fn npc_role_is_market(role: &str) -> bool {
2167 role.eq_ignore_ascii_case("market_clerk")
2168 }
2169
2170 pub fn bank_menu_options(&self) -> Vec<&'static str> {
2171 vec![
2172 "Deposit…",
2173 "Withdraw…",
2174 "Deposit all",
2175 "Withdraw all",
2176 "Transfer…",
2177 ]
2178 }
2179
2180 pub fn storage_menu_options(&self) -> Vec<String> {
2181 let mut opts = vec!["Store…".into(), "Take…".into()];
2182 if let Some(panel) = &self.storage_panel {
2183 for dest in &panel.ship_destinations {
2184 opts.push(format!(
2185 "Ship → {} ({} cp / {} ticks)",
2186 dest.label, dest.fee_copper, dest.travel_ticks
2187 ));
2188 }
2189 }
2190 opts
2191 }
2192
2193 pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
2197 let equipped = self.hand_equipped_instance_ids();
2198 self.person_rows()
2199 .into_iter()
2200 .filter(|r| r.depth == 0)
2201 .filter_map(|r| {
2202 let id = r.stack.item_instance_id?;
2203 if equipped.contains(&id) {
2204 return None;
2205 }
2206 Some(StoragePickOption {
2207 item_instance_id: id,
2208 template_id: r.stack.template_id.clone(),
2209 label: storage_stack_label(&r.stack),
2210 quantity: r.stack.quantity,
2211 category: r.stack.category.clone().unwrap_or_default(),
2212 })
2213 })
2214 .collect()
2215 }
2216
2217 pub fn hand_equipped_instance_ids(&self) -> std::collections::HashSet<uuid::Uuid> {
2219 let mut ids = std::collections::HashSet::new();
2220 if let Some(id) = self.mainhand_instance_id {
2221 ids.insert(id);
2222 } else if let Some(tid) = &self.mainhand_template_id {
2223 if let Some(id) = self
2224 .inventory_stacks
2225 .iter()
2226 .find(|s| &s.template_id == tid)
2227 .and_then(|s| s.item_instance_id)
2228 {
2229 ids.insert(id);
2230 }
2231 }
2232 if let Some(id) = self.offhand_instance_id {
2233 ids.insert(id);
2234 } else if let Some(tid) = &self.offhand_template_id {
2235 if let Some(id) = self
2236 .inventory_stacks
2237 .iter()
2238 .find(|s| {
2239 &s.template_id == tid
2240 && s.item_instance_id.is_some_and(|iid| !ids.contains(&iid))
2241 })
2242 .and_then(|s| s.item_instance_id)
2243 {
2244 ids.insert(id);
2245 }
2246 }
2247 ids
2248 }
2249
2250 pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
2252 let Some(panel) = &self.storage_panel else {
2253 return Vec::new();
2254 };
2255 panel
2256 .contents
2257 .iter()
2258 .filter_map(|s| {
2259 let id = s.item_instance_id?;
2260 Some(StoragePickOption {
2261 item_instance_id: id,
2262 template_id: s.template_id.clone(),
2263 label: storage_stack_label(s),
2264 quantity: s.quantity,
2265 category: s.category.clone().unwrap_or_default(),
2266 })
2267 })
2268 .collect()
2269 }
2270
2271 pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
2273 let mut opts = Vec::new();
2274 if !self
2275 .market_list_item_options(&MarketListSourceKind::Person)
2276 .is_empty()
2277 {
2278 opts.push((MarketListSourceKind::Person, "On person".into()));
2279 }
2280 if let Some(panel) = &self.market_panel {
2281 for vault in &panel.list_vaults {
2282 let source = MarketListSourceKind::TownStorage {
2283 building_id: vault.building_id.clone(),
2284 };
2285 if self.market_list_item_options(&source).is_empty() {
2286 continue;
2287 }
2288 let label = if vault.building_label.is_empty() {
2289 format!("Town storage ({})", vault.building_id)
2290 } else {
2291 format!("Town storage — {}", vault.building_label)
2292 };
2293 opts.push((source, label));
2294 }
2295 }
2296 opts
2297 }
2298
2299 pub fn market_list_item_options(
2301 &self,
2302 source: &MarketListSourceKind,
2303 ) -> Vec<StoragePickOption> {
2304 let filter = self.market_filter.as_str();
2305 let cat_filter = self.market_category_filter;
2306 let mut opts: Vec<StoragePickOption> = match source {
2307 MarketListSourceKind::Person => {
2308 let equipped = self.hand_equipped_instance_ids();
2309 self.person_rows()
2310 .into_iter()
2311 .filter(|r| r.depth == 0)
2312 .filter(|r| self.stack_is_market_listable(&r.stack))
2313 .filter_map(|r| {
2314 let id = r.stack.item_instance_id?;
2315 if equipped.contains(&id) {
2316 return None;
2317 }
2318 Some(StoragePickOption {
2319 item_instance_id: id,
2320 template_id: r.stack.template_id.clone(),
2321 label: storage_stack_label(&r.stack),
2322 quantity: r.stack.quantity,
2323 category: r
2324 .stack
2325 .category
2326 .clone()
2327 .or_else(|| {
2328 self.inventory_item_category(&r.stack.template_id)
2329 .map(str::to_string)
2330 })
2331 .unwrap_or_default(),
2332 })
2333 })
2334 .collect()
2335 }
2336 MarketListSourceKind::TownStorage { building_id } => {
2337 let Some(panel) = &self.market_panel else {
2338 return Vec::new();
2339 };
2340 let Some(vault) = panel
2341 .list_vaults
2342 .iter()
2343 .find(|v| &v.building_id == building_id)
2344 else {
2345 return Vec::new();
2346 };
2347 vault
2348 .contents
2349 .iter()
2350 .filter(|s| self.stack_is_market_listable(s))
2351 .filter_map(|s| {
2352 let id = s.item_instance_id?;
2353 Some(StoragePickOption {
2354 item_instance_id: id,
2355 template_id: s.template_id.clone(),
2356 label: storage_stack_label(s),
2357 quantity: s.quantity,
2358 category: s
2359 .category
2360 .clone()
2361 .or_else(|| {
2362 self.inventory_item_category(&s.template_id)
2363 .map(str::to_string)
2364 })
2365 .unwrap_or_default(),
2366 })
2367 })
2368 .collect()
2369 }
2370 };
2371 opts.retain(|o| {
2372 if !list_label_matches(&o.label, filter) {
2373 return false;
2374 }
2375 if let Some(group) = cat_filter {
2376 inventory_category_group(&o.category).0 == group
2377 } else {
2378 true
2379 }
2380 });
2381 opts
2382 }
2383
2384 pub fn item_base_value_copper_hint(&self, template_id: &str) -> Option<u32> {
2386 if let Some(hint) = self.inventory_hints.get(template_id) {
2387 if let Some(v) = hint.base_value_copper.filter(|v| *v > 0) {
2388 return Some(v);
2389 }
2390 }
2391 if let Some(v) = self
2392 .inventory_stacks
2393 .iter()
2394 .find(|s| s.template_id == template_id)
2395 .and_then(|s| s.base_value_copper.filter(|v| *v > 0))
2396 {
2397 return Some(v);
2398 }
2399 self.market_panel.as_ref().and_then(|panel| {
2400 panel.list_vaults.iter().find_map(|vault| {
2401 vault.contents.iter().find_map(|stack| {
2402 (stack.template_id == template_id)
2403 .then(|| stack.base_value_copper.filter(|v| *v > 0))
2404 .flatten()
2405 })
2406 })
2407 })
2408 }
2409
2410 pub fn npc_market_dump_unit_estimate(&self, template_id: &str) -> Option<u32> {
2412 let base = self.item_base_value_copper_hint(template_id)?;
2413 npc_market_dump_unit_estimate_copper(base)
2414 }
2415
2416 fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
2417 if crate::currency::is_currency(&stack.template_id) {
2418 return false;
2419 }
2420 if let Some(flag) = stack.listable {
2421 return flag;
2422 }
2423 if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
2424 return hint.listable;
2425 }
2426 let cat = stack
2427 .category
2428 .as_deref()
2429 .or_else(|| self.inventory_item_category(&stack.template_id))
2430 .unwrap_or("");
2431 category_default_listable(cat)
2432 }
2433
2434 pub fn market_available_category_groups(&self) -> Vec<&'static str> {
2436 let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
2437 match &self.market_ui_mode {
2438 MarketUiMode::ListPick { source, .. } => {
2439 let raw: Vec<_> = match source {
2440 MarketListSourceKind::Person => self
2441 .person_rows()
2442 .into_iter()
2443 .filter(|r| r.depth == 0)
2444 .filter(|r| self.stack_is_market_listable(&r.stack))
2445 .filter(|r| {
2446 list_label_matches(&storage_stack_label(&r.stack), &self.market_filter)
2447 })
2448 .map(|r| {
2449 r.stack
2450 .category
2451 .clone()
2452 .or_else(|| {
2453 self.inventory_item_category(&r.stack.template_id)
2454 .map(str::to_string)
2455 })
2456 .unwrap_or_default()
2457 })
2458 .collect(),
2459 MarketListSourceKind::TownStorage { building_id } => self
2460 .market_panel
2461 .as_ref()
2462 .and_then(|p| p.list_vaults.iter().find(|v| &v.building_id == building_id))
2463 .map(|vault| {
2464 vault
2465 .contents
2466 .iter()
2467 .filter(|s| self.stack_is_market_listable(s))
2468 .filter(|s| {
2469 list_label_matches(&storage_stack_label(s), &self.market_filter)
2470 })
2471 .map(|s| {
2472 s.category
2473 .clone()
2474 .or_else(|| {
2475 self.inventory_item_category(&s.template_id)
2476 .map(str::to_string)
2477 })
2478 .unwrap_or_default()
2479 })
2480 .collect::<Vec<_>>()
2481 })
2482 .unwrap_or_default(),
2483 };
2484 for category in raw {
2485 let (label, ord) = inventory_category_group(&category);
2486 seen.insert(ord, label);
2487 }
2488 }
2489 _ => {
2490 if let Some(panel) = &self.market_panel {
2491 for listing in &panel.listings {
2492 if !list_label_matches(&listing.display_name, &self.market_filter)
2493 && !list_label_matches(&listing.seller_label, &self.market_filter)
2494 {
2495 continue;
2496 }
2497 let (label, ord) = inventory_category_group(&listing.category);
2498 seen.insert(ord, label);
2499 }
2500 }
2501 }
2502 }
2503 seen.into_values().collect()
2504 }
2505
2506 pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
2508 let Some(panel) = &self.market_panel else {
2509 return Vec::new();
2510 };
2511 let filter = self.market_filter.as_str();
2512 let cat_filter = self.market_category_filter;
2513 panel
2514 .listings
2515 .iter()
2516 .enumerate()
2517 .filter(|(_, listing)| {
2518 if !list_label_matches(&listing.display_name, filter)
2519 && !list_label_matches(&listing.seller_label, filter)
2520 && !list_label_matches(&listing.template_id, filter)
2521 {
2522 return false;
2523 }
2524 if let Some(group) = cat_filter {
2525 inventory_category_group(&listing.category).0 == group
2526 } else {
2527 true
2528 }
2529 })
2530 .map(|(i, _)| i)
2531 .collect()
2532 }
2533
2534 pub fn clear_harvest_state(&mut self) {
2535 self.harvest_in_progress = false;
2536 self.harvest_started_at = None;
2537 }
2538
2539 fn harvest_state_stale(&self) -> bool {
2540 match self.harvest_started_at {
2541 Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
2542 None => self.harvest_in_progress,
2543 }
2544 }
2545
2546 pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
2547 self.player.as_ref().and_then(|p| p.vitals)
2548 }
2549
2550 pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
2551 let materials_ok = blueprint.inputs.iter().all(|input| {
2552 self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
2553 });
2554 let tools_ok = blueprint
2555 .required_tools
2556 .iter()
2557 .all(|tool| self.player_has_craft_tool(&tool.item));
2558 let station_ok = match blueprint.station.as_deref() {
2559 None | Some("hand") => true,
2560 Some(tag) => self.player_at_station_tag(tag),
2561 };
2562 materials_ok
2563 && tools_ok
2564 && station_ok
2565 && self.craft_has_vessel_room_for_output(blueprint)
2566 }
2567
2568 pub fn player_has_craft_tool(&self, tool_template: &str) -> bool {
2570 if self.inventory.get(tool_template).copied().unwrap_or(0) >= 1 {
2571 return true;
2572 }
2573 let Some(player) = self.player.as_ref() else {
2574 return false;
2575 };
2576 let px = player.transform.position.x;
2577 let py = player.transform.position.y;
2578 const RANGE: f32 = 3.0;
2580 self.placed_containers.iter().any(|c| {
2581 if c.template_id != tool_template {
2582 return false;
2583 }
2584 if !self.placed_container_in_current_space(c) {
2585 return false;
2586 }
2587 let dx = c.x - px;
2588 let dy = c.y - py;
2589 dx * dx + dy * dy <= RANGE * RANGE
2590 })
2591 }
2592
2593 fn craft_output_needs_vessel(&self, blueprint: &BlueprintView) -> bool {
2595 matches!(
2596 self.inventory_item_category(&blueprint.output),
2597 Some("bulk") | Some("liquid")
2598 ) || matches!(
2599 blueprint.output.as_str(),
2600 "dirt" | "mud" | "sand" | "water" | "milk"
2601 )
2602 }
2603
2604 fn craft_output_category(&self, blueprint: &BlueprintView) -> Option<&str> {
2605 self.inventory_item_category(&blueprint.output).or_else(|| {
2606 match blueprint.output.as_str() {
2607 "dirt" | "mud" | "sand" => Some("bulk"),
2608 "water" | "milk" => Some("liquid"),
2609 _ => None,
2610 }
2611 })
2612 }
2613
2614 fn craft_has_vessel_room_for_output(&self, blueprint: &BlueprintView) -> bool {
2615 if !self.craft_output_needs_vessel(blueprint) {
2616 return true;
2617 }
2618 let need = blueprint.output_qty.max(1);
2619 self.vessel_room_after_craft_inputs(blueprint) >= need
2620 }
2621
2622 fn vessel_room_after_craft_inputs(&self, blueprint: &BlueprintView) -> u32 {
2624 let mut stacks = self.inventory_stacks.clone();
2625 for worn in self.worn.values() {
2626 stacks.push(worn.clone());
2627 }
2628 for input in &blueprint.inputs {
2629 let mut left = input.quantity;
2630 drain_payload_from_stacks(&mut stacks, &input.template_id, &mut left);
2631 if left > 0 {
2632 return 0;
2633 }
2634 }
2635 vessel_room_for_payload_in_stacks(
2636 &stacks,
2637 &blueprint.output,
2638 self.craft_output_category(blueprint),
2639 )
2640 }
2641
2642 pub fn craft_vessel_status(&self, blueprint: &BlueprintView) -> CraftVesselStatus {
2644 let output_label = self.blueprint_output_label(blueprint);
2645 let needs_vessel = self.craft_output_needs_vessel(blueprint);
2646 let need_units = if needs_vessel {
2647 blueprint.output_qty.max(1)
2648 } else {
2649 0
2650 };
2651 let free_after_inputs = if needs_vessel {
2652 self.vessel_room_after_craft_inputs(blueprint)
2653 } else {
2654 0
2655 };
2656 let payload_cat = self.craft_output_category(blueprint);
2657 let mut vessels = Vec::new();
2658 Self::collect_craft_vessel_lines(
2659 &self.inventory_stacks,
2660 "pack",
2661 &blueprint.output,
2662 payload_cat,
2663 &mut vessels,
2664 );
2665 for worn in self.worn.values() {
2666 Self::collect_craft_vessel_lines(
2667 std::slice::from_ref(worn),
2668 "worn",
2669 &blueprint.output,
2670 payload_cat,
2671 &mut vessels,
2672 );
2673 }
2674 CraftVesselStatus {
2675 needs_vessel,
2676 output_label,
2677 need_units,
2678 free_after_inputs,
2679 ok: !needs_vessel || free_after_inputs >= need_units,
2680 vessels,
2681 }
2682 }
2683
2684 fn collect_craft_vessel_lines(
2685 stacks: &[flatland_protocol::ItemStack],
2686 location: &'static str,
2687 payload_id: &str,
2688 payload_category: Option<&str>,
2689 out: &mut Vec<CraftVesselLine>,
2690 ) {
2691 for stack in stacks {
2692 if is_serving_vessel_stack(stack) {
2693 let cap = serving_capacity_of(stack);
2694 let used = payload_units_in_vessel(stack);
2695 let free = vessel_free_room_for_payload(stack, payload_id, payload_category);
2696 let holds = stack
2697 .props
2698 .get("serving_holds")
2699 .cloned()
2700 .unwrap_or_else(|| {
2701 if stack.props.get("bulk_vessel").is_some_and(|v| v == "1")
2702 && stack.props.get("liquid_vessel").is_some_and(|v| v == "1")
2703 {
2704 "liquid,bulk".into()
2705 } else if stack.props.get("bulk_vessel").is_some_and(|v| v == "1") {
2706 "bulk".into()
2707 } else if stack.props.get("liquid_vessel").is_some_and(|v| v == "1") {
2708 "liquid".into()
2709 } else {
2710 "?".into()
2711 }
2712 });
2713 let label = stack
2714 .display_name
2715 .clone()
2716 .unwrap_or_else(|| stack.template_id.clone());
2717 out.push(CraftVesselLine {
2718 label,
2719 holds,
2720 capacity: cap,
2721 used,
2722 free,
2723 quantity: stack.quantity.max(1),
2724 accepts_output: free > 0,
2725 location,
2726 });
2727 }
2728 Self::collect_craft_vessel_lines(
2729 &stack.contents,
2730 location,
2731 payload_id,
2732 payload_category,
2733 out,
2734 );
2735 }
2736 }
2737
2738 fn craft_prefs_key(&self) -> String {
2739 if let Some(cid) = self.character_id {
2740 cid.to_string()
2741 } else if self.entity_id != 0 {
2742 format!("entity:{}", self.entity_id)
2743 } else {
2744 String::new()
2745 }
2746 }
2747
2748 pub fn reload_craft_prefs(&mut self) {
2749 let key = self.craft_prefs_key();
2750 self.craft_prefs = crate::craft_prefs::load_for_character(&key);
2751 }
2752
2753 fn persist_craft_prefs(&self) {
2754 crate::craft_prefs::save_for_character(&self.craft_prefs_key(), &self.craft_prefs);
2755 }
2756
2757 pub fn craft_known_tiers(&self) -> Vec<u32> {
2759 let mut tiers: Vec<u32> = self
2760 .blueprints
2761 .iter()
2762 .map(|bp| bp.craft_tier.max(1))
2763 .collect::<std::collections::BTreeSet<_>>()
2764 .into_iter()
2765 .collect();
2766 tiers.sort_unstable();
2767 tiers
2768 }
2769
2770 pub fn craft_tab_strip(&self) -> Vec<CraftTab> {
2772 let mut tabs = vec![CraftTab::Ready, CraftTab::Favorites, CraftTab::Recent];
2773 for t in self.craft_known_tiers() {
2774 tabs.push(CraftTab::Tier(t));
2775 }
2776 tabs
2777 }
2778
2779 pub fn craft_set_tab(&mut self, tab: CraftTab) {
2780 self.craft_tab = tab;
2781 self.craft_menu_index = 0;
2782 self.clamp_craft_menu_index();
2783 self.clamp_craft_batch_quantity();
2784 }
2785
2786 pub fn craft_cycle_tab(&mut self, delta: i32) {
2787 let tabs = self.craft_tab_strip();
2788 if tabs.is_empty() {
2789 return;
2790 }
2791 let cur = tabs
2792 .iter()
2793 .position(|t| *t == self.craft_tab)
2794 .unwrap_or(0) as i32;
2795 let next = (cur + delta).rem_euclid(tabs.len() as i32) as usize;
2796 self.craft_set_tab(tabs[next]);
2797 }
2798
2799 pub fn craft_matches_search(&self, bp: &BlueprintView) -> bool {
2800 let f = self.craft_filter.trim();
2801 if f.is_empty() {
2802 return true;
2803 }
2804 if list_label_matches(&bp.label, f)
2805 || list_label_matches(&bp.output, f)
2806 || list_label_matches(&bp.output_display_name, f)
2807 || bp
2808 .category
2809 .as_deref()
2810 .is_some_and(|c| list_label_matches(c, f))
2811 || bp
2812 .station
2813 .as_deref()
2814 .is_some_and(|s| list_label_matches(s, f))
2815 {
2816 return true;
2817 }
2818 bp.inputs.iter().any(|i| {
2819 list_label_matches(&i.template_id, f) || list_label_matches(&i.display_name, f)
2820 }) || bp.required_tools.iter().any(|t| {
2821 list_label_matches(&t.item, f) || list_label_matches(&t.display_name, f)
2822 })
2823 }
2824
2825 pub fn craft_blueprint_in_channel(&self, blueprint_id: &str) -> bool {
2827 self.craft_channel_blueprint_id.as_deref() == Some(blueprint_id)
2828 && self.active_craft_channel().is_some()
2829 }
2830
2831 pub fn craft_filtered_indices(&self) -> Vec<usize> {
2833 let mut idxs: Vec<usize> = (0..self.blueprints.len())
2834 .filter(|&i| {
2835 let bp = &self.blueprints[i];
2836 if !self.craft_matches_search(bp) {
2837 return false;
2838 }
2839 match self.craft_tab {
2840 CraftTab::Ready => {
2841 self.can_craft_blueprint(bp) || self.craft_blueprint_in_channel(&bp.id)
2842 }
2843 CraftTab::Favorites => self.craft_prefs.is_favorite(&bp.id),
2844 CraftTab::Recent => self.craft_prefs.recent.iter().any(|id| id == &bp.id),
2845 CraftTab::Tier(t) => bp.craft_tier.max(1) == t,
2846 }
2847 })
2848 .collect();
2849 match self.craft_tab {
2850 CraftTab::Recent => {
2851 idxs.sort_by_key(|&i| {
2852 self.craft_prefs
2853 .recent
2854 .iter()
2855 .position(|id| id == &self.blueprints[i].id)
2856 .unwrap_or(usize::MAX)
2857 });
2858 }
2859 _ => {
2860 idxs.sort_by(|&a, &b| {
2861 let ba = &self.blueprints[a];
2862 let bb = &self.blueprints[b];
2863 let ia = self.craft_blueprint_in_channel(&ba.id);
2864 let ib = self.craft_blueprint_in_channel(&bb.id);
2865 ib.cmp(&ia)
2867 .then_with(|| {
2868 let ra = self.can_craft_blueprint(ba);
2869 let rb = self.can_craft_blueprint(bb);
2870 rb.cmp(&ra)
2871 })
2872 .then_with(|| ba.label.to_ascii_lowercase().cmp(&bb.label.to_ascii_lowercase()))
2873 });
2874 }
2875 }
2876 idxs
2877 }
2878
2879 pub fn craft_selected_blueprint(&self) -> Option<&BlueprintView> {
2880 let idxs = self.craft_filtered_indices();
2881 idxs.get(self.craft_menu_index)
2882 .and_then(|&i| self.blueprints.get(i))
2883 }
2884
2885 pub fn clamp_craft_menu_index(&mut self) {
2886 let n = self.craft_filtered_indices().len();
2887 if n == 0 {
2888 self.craft_menu_index = 0;
2889 } else {
2890 self.craft_menu_index = self.craft_menu_index.min(n - 1);
2891 }
2892 }
2893
2894 pub fn craft_is_favorite(&self, blueprint_id: &str) -> bool {
2895 self.craft_prefs.is_favorite(blueprint_id)
2896 }
2897
2898 pub fn craft_toggle_favorite_selected(&mut self) {
2899 let Some(id) = self.craft_selected_blueprint().map(|bp| bp.id.clone()) else {
2900 return;
2901 };
2902 self.craft_prefs.toggle_favorite(&id);
2903 self.persist_craft_prefs();
2904 if matches!(self.craft_tab, CraftTab::Favorites) {
2905 self.clamp_craft_menu_index();
2906 }
2907 }
2908
2909 pub fn craft_record_completed(&mut self, blueprint_id: &str) {
2910 self.craft_prefs.record_crafted(blueprint_id);
2911 self.persist_craft_prefs();
2912 }
2913
2914 pub fn focus_craft_filter(&mut self) {
2915 self.craft_filter_focused = true;
2916 }
2917
2918 pub fn append_craft_filter_char(&mut self, ch: char) {
2919 if !self.craft_filter_focused {
2920 return;
2921 }
2922 if is_list_filter_char(ch) {
2923 self.craft_filter.push(ch);
2924 self.craft_menu_index = 0;
2925 self.clamp_craft_menu_index();
2926 }
2927 }
2928
2929 pub fn craft_filter_backspace(&mut self) {
2930 if !self.craft_filter_focused {
2931 return;
2932 }
2933 self.craft_filter.pop();
2934 self.craft_menu_index = 0;
2935 self.clamp_craft_menu_index();
2936 }
2937
2938 pub fn clear_or_blur_craft_filter(&mut self) -> bool {
2940 if self.craft_filter_focused {
2941 if !self.craft_filter.is_empty() {
2942 self.craft_filter.clear();
2943 self.craft_menu_index = 0;
2944 self.clamp_craft_menu_index();
2945 } else {
2946 self.craft_filter_focused = false;
2947 }
2948 return true;
2949 }
2950 if !self.craft_filter.is_empty() {
2951 self.craft_filter.clear();
2952 self.craft_menu_index = 0;
2953 self.clamp_craft_menu_index();
2954 return true;
2955 }
2956 false
2957 }
2958
2959 pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
2960 if !self.can_craft_blueprint(blueprint) {
2961 return 0;
2962 }
2963 let mut limit = u32::MAX;
2964 for input in &blueprint.inputs {
2965 if input.quantity == 0 {
2966 continue;
2967 }
2968 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2969 limit = limit.min(have / input.quantity);
2970 }
2971 for tool in &blueprint.required_tools {
2972 if tool.consumed {
2973 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
2974 limit = limit.min(have);
2975 }
2976 }
2977 if self.craft_output_needs_vessel(blueprint) {
2978 let need = blueprint.output_qty.max(1);
2979 let room = self.vessel_room_after_craft_inputs(blueprint);
2980 if need > 0 {
2981 limit = limit.min(room / need);
2982 }
2983 }
2984 limit.min(CRAFT_BATCH_SELECT_CAP)
2985 }
2986
2987 pub fn craft_stamina_batch_cap(&self) -> u32 {
2989 let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
2990 if CRAFT_STAMINA_COST > 0.0 {
2991 (stamina / CRAFT_STAMINA_COST).floor() as u32
2992 } else {
2993 u32::MAX
2994 }
2995 }
2996
2997 pub fn clamp_craft_batch_quantity(&mut self) {
2998 let Some(bp) = self.craft_selected_blueprint().cloned() else {
2999 self.craft_batch_quantity = 1;
3000 return;
3001 };
3002 let max = self.max_craft_batches(&bp).max(1);
3003 self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
3004 }
3005
3006 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
3007 let Some(bp) = self.craft_selected_blueprint().cloned() else {
3008 return;
3009 };
3010 let max = self.max_craft_batches(&bp).max(1);
3011 let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
3012 self.craft_batch_quantity = next as u32;
3013 }
3014
3015 pub fn craft_batch_set_max(&mut self) {
3016 let Some(bp) = self.craft_selected_blueprint().cloned() else {
3017 return;
3018 };
3019 let max = self.max_craft_batches(&bp);
3020 self.craft_batch_quantity = if max == 0 { 1 } else { max };
3021 }
3022
3023 pub fn craft_batch_set_min(&mut self) {
3024 self.craft_batch_quantity = 1;
3025 }
3026
3027 pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
3028 let preserve_ui = self.show_shop_menu;
3029 let tab = self.shop_tab;
3030 let index = self.shop_menu_index;
3031 let qty = self.shop_quantity;
3032
3033 self.show_shop_menu = true;
3034 self.bank_panel = None;
3035 self.show_craft_menu = false;
3036 self.show_inventory_menu = false;
3037 self.show_stats = false;
3038 if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
3039 self.npc_verb_target = Some(catalog.npc_id.clone());
3040 }
3041 self.shop_catalog = Some(catalog);
3042
3043 if preserve_ui {
3044 self.shop_tab = tab;
3045 self.shop_menu_index = index;
3046 self.shop_quantity = qty;
3047 } else {
3048 self.shop_tab = ShopTab::Buy;
3049 self.shop_menu_index = 0;
3050 self.shop_quantity = 1;
3051 self.clear_shop_trade_log();
3052 }
3053 self.show_npc_verb_menu = false;
3054 self.clamp_shop_selection();
3055 }
3056
3057 pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
3058 let same_teller = self
3059 .bank_panel
3060 .as_ref()
3061 .is_some_and(|p| p.npc_id == panel.npc_id);
3062 self.bank_panel = Some(panel);
3063 self.storage_panel = None;
3064 self.market_panel = None;
3065 self.shop_catalog = None;
3066 self.show_shop_menu = false;
3067 self.show_craft_menu = false;
3068 self.show_inventory_menu = false;
3069 self.show_stats = false;
3070 self.show_npc_verb_menu = false;
3071 self.show_npc_chat = false;
3072 self.npc_chat = None;
3073 if !same_teller {
3074 self.bank_menu_index = 0;
3075 self.bank_ui_mode = BankUiMode::Menu;
3076 }
3077 if let Some(panel) = &self.bank_panel {
3078 if self.npc_verb_target.is_none() {
3079 self.npc_verb_target = Some(panel.npc_id.clone());
3080 }
3081 }
3082 }
3083
3084 pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
3085 let same_manager = self
3086 .storage_panel
3087 .as_ref()
3088 .is_some_and(|p| p.npc_id == panel.npc_id);
3089 self.storage_panel = Some(panel);
3090 self.bank_panel = None;
3091 self.market_panel = None;
3092 self.bank_ui_mode = BankUiMode::Menu;
3093 self.shop_catalog = None;
3094 self.show_shop_menu = false;
3095 self.show_craft_menu = false;
3096 self.show_inventory_menu = false;
3097 self.show_stats = false;
3098 self.show_npc_verb_menu = false;
3099 self.show_npc_chat = false;
3100 self.npc_chat = None;
3101 if !same_manager {
3102 self.storage_menu_index = 0;
3103 self.storage_ui_mode = StorageUiMode::Menu;
3104 } else {
3105 self.clamp_storage_pick_index();
3106 }
3107 if let Some(panel) = &self.storage_panel {
3108 if self.npc_verb_target.is_none() {
3109 self.npc_verb_target = Some(panel.npc_id.clone());
3110 }
3111 }
3112 }
3113
3114 pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
3115 for vault in &panel.list_vaults {
3116 self.merge_stack_catalog_hints(&vault.contents);
3117 }
3118 self.market_panel = Some(panel);
3119 self.bank_panel = None;
3120 self.storage_panel = None;
3121 self.shop_catalog = None;
3122 self.show_shop_menu = false;
3123 self.show_craft_menu = false;
3124 self.show_inventory_menu = false;
3125 self.show_stats = false;
3126 self.show_npc_verb_menu = false;
3127 self.show_npc_chat = false;
3128 self.npc_chat = None;
3129 self.market_menu_index = 0;
3130 self.market_buy_confirm = None;
3131 self.market_ui_mode = MarketUiMode::Browse;
3132 self.market_filter.clear();
3133 self.market_filter_focused = false;
3134 self.market_category_filter = None;
3135 if let Some(panel) = &self.market_panel {
3136 if self.npc_verb_target.is_none() {
3137 self.npc_verb_target = Some(panel.npc_id.clone());
3138 }
3139 }
3140 }
3141
3142 pub fn clear_market_panel(&mut self) {
3143 self.market_panel = None;
3144 self.market_menu_index = 0;
3145 self.market_buy_confirm = None;
3146 self.market_ui_mode = MarketUiMode::Browse;
3147 self.market_filter.clear();
3148 self.market_filter_focused = false;
3149 self.market_category_filter = None;
3150 }
3151
3152 pub fn clear_bank_panel(&mut self) {
3153 self.bank_panel = None;
3154 self.bank_menu_index = 0;
3155 self.bank_ui_mode = BankUiMode::Menu;
3156 }
3157
3158 pub fn clear_storage_panel(&mut self) {
3159 self.storage_panel = None;
3160 self.storage_menu_index = 0;
3161 self.storage_ui_mode = StorageUiMode::Menu;
3162 }
3163
3164 fn clamp_storage_pick_index(&mut self) {
3165 match &self.storage_ui_mode {
3166 StorageUiMode::StorePick { index } => {
3167 let n = self.storage_store_options().len();
3168 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3169 self.storage_ui_mode = StorageUiMode::StorePick { index: next };
3170 }
3171 StorageUiMode::TakePick { index } => {
3172 let n = self.storage_vault_options().len();
3173 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3174 self.storage_ui_mode = StorageUiMode::TakePick { index: next };
3175 }
3176 StorageUiMode::ShipPick {
3177 dest_building_id,
3178 dest_label,
3179 index,
3180 } => {
3181 let n = self.storage_vault_options().len();
3182 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3183 self.storage_ui_mode = StorageUiMode::ShipPick {
3184 dest_building_id: dest_building_id.clone(),
3185 dest_label: dest_label.clone(),
3186 index: next,
3187 };
3188 }
3189 StorageUiMode::Menu
3190 | StorageUiMode::StoreAmount { .. }
3191 | StorageUiMode::TakeAmount { .. }
3192 | StorageUiMode::ShipAmount { .. } => {}
3193 }
3194 }
3195
3196 pub fn shop_list_len(&self) -> usize {
3197 let Some(catalog) = &self.shop_catalog else {
3198 return 0;
3199 };
3200 match self.shop_tab {
3201 ShopTab::Buy => catalog.sells.len(),
3202 ShopTab::Sell => catalog.buys.len(),
3203 }
3204 }
3205
3206 pub fn shop_menu_move(&mut self, delta: i32) {
3207 let n = self.shop_list_len();
3208 if n == 0 {
3209 return;
3210 }
3211 let idx = self.shop_menu_index as i32;
3212 let next = (idx + delta).rem_euclid(n as i32);
3213 self.shop_menu_index = next as usize;
3214 self.clamp_shop_quantity();
3215 }
3216
3217 pub fn shop_quantity_adjust(&mut self, delta: i32) {
3218 let max = self.shop_quantity_max();
3219 if max == 0 {
3220 self.shop_quantity = 0;
3221 return;
3222 }
3223 let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
3224 self.shop_quantity = next as u32;
3225 }
3226
3227 pub(crate) fn clamp_shop_selection(&mut self) {
3228 let n = self.shop_list_len();
3229 if n == 0 {
3230 self.shop_menu_index = 0;
3231 } else {
3232 self.shop_menu_index = self.shop_menu_index.min(n - 1);
3233 }
3234 self.clamp_shop_quantity();
3235 }
3236
3237 fn shop_quantity_max(&self) -> u32 {
3238 let Some(catalog) = &self.shop_catalog else {
3239 return 1;
3240 };
3241 match self.shop_tab {
3242 ShopTab::Buy => {
3243 if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
3244 if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
3245 return 1;
3246 }
3247 }
3248 99
3249 }
3250 ShopTab::Sell => catalog
3251 .buys
3252 .get(self.shop_menu_index)
3253 .map(|l| l.quantity)
3254 .unwrap_or(0),
3255 }
3256 }
3257
3258 pub fn shop_quantity_set_max(&mut self) {
3259 self.shop_quantity = self.shop_quantity_max();
3260 }
3261
3262 pub fn shop_quantity_set_min(&mut self) {
3263 let max = self.shop_quantity_max();
3264 self.shop_quantity = if max == 0 { 0 } else { 1 };
3265 }
3266
3267 fn clamp_shop_quantity(&mut self) {
3268 let max = self.shop_quantity_max();
3269 if max == 0 {
3270 self.shop_quantity = 0;
3271 } else {
3272 self.shop_quantity = self.shop_quantity.max(1).min(max);
3273 }
3274 }
3275
3276 pub fn player_at_station_tag(&self, tag: &str) -> bool {
3277 let Some(id) = self.effective_inside_building() else {
3278 return false;
3279 };
3280 self.buildings
3281 .iter()
3282 .find(|b| b.id == id)
3283 .is_some_and(|b| b.tags.iter().any(|t| t == tag))
3284 }
3285
3286 pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
3288 if self.can_craft_blueprint(blueprint) {
3289 return None;
3290 }
3291 let mut missing = Vec::new();
3292 for input in &blueprint.inputs {
3293 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
3294 if have < input.quantity {
3295 let name = self.blueprint_ingredient_label(input);
3296 let vessel_note = if self.inventory_item_category(&input.template_id)
3297 == Some("liquid")
3298 || matches!(input.template_id.as_str(), "water" | "milk")
3299 {
3300 "; fill a bottle/waterskin"
3301 } else if self.inventory_item_category(&input.template_id) == Some("bulk")
3302 || matches!(input.template_id.as_str(), "dirt" | "mud" | "sand")
3303 {
3304 "; scoop into a sack/bucket"
3305 } else {
3306 ""
3307 };
3308 missing.push(format!(
3309 "{}×{} (have {have}{vessel_note})",
3310 input.quantity, name
3311 ));
3312 }
3313 }
3314 for tool in &blueprint.required_tools {
3315 if !self.player_has_craft_tool(&tool.item) {
3316 missing.push(format!("tool: {}", self.blueprint_tool_label(tool)));
3317 }
3318 }
3319 if let Some(station) = blueprint.station.as_deref() {
3320 if station != "hand" && !self.player_at_station_tag(station) {
3321 missing.push(format!("station: {station} (enter building)"));
3322 }
3323 }
3324 if self.craft_output_needs_vessel(blueprint) && !self.craft_has_vessel_room_for_output(blueprint)
3325 {
3326 let name = self
3327 .inventory_hints
3328 .get(&blueprint.output)
3329 .map(|h| h.display_name.as_str())
3330 .unwrap_or(blueprint.output.as_str());
3331 let need = blueprint.output_qty.max(1);
3332 let free = self.vessel_room_after_craft_inputs(blueprint);
3333 let accepting = self
3334 .craft_vessel_status(blueprint)
3335 .vessels
3336 .iter()
3337 .filter(|v| v.accepts_output)
3338 .count();
3339 missing.push(format!(
3340 "vessel room for {name}: need {need} free after inputs, have {free} ({accepting} accepting vessel(s))"
3341 ));
3342 }
3343 if missing.is_empty() {
3344 None
3345 } else {
3346 Some(missing.join(", "))
3347 }
3348 }
3349
3350 pub fn active_craft_channel(&self) -> Option<&flatland_protocol::TimedChannelHud> {
3352 self.timed_channel
3353 .as_ref()
3354 .filter(|c| c.channel == flatland_protocol::TimedChannelKind::Craft)
3355 }
3356
3357 pub fn player_entity(&self) -> Option<&EntityState> {
3358 self.player
3359 .as_ref()
3360 .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
3361 }
3362
3363 pub fn apply_client_ui_prefs(&mut self) {
3365 let cfg = crate::client_config::ClientConfig::load();
3366 if let Some(hidden) = cfg.hud_log_hidden {
3367 self.hud_log_hidden = hidden;
3368 }
3369 if let Some(compact) = cfg.workers_menu_compact {
3370 self.workers_menu_compact = compact;
3371 }
3372 }
3373
3374 pub fn player_position(&self) -> (f32, f32) {
3375 let (x, y, _) = self.player_position_with_z();
3376 (x, y)
3377 }
3378
3379 pub fn player_position_with_z(&self) -> (f32, f32, f32) {
3380 if let Some(p) = self.player_entity() {
3381 (
3382 p.transform.position.x,
3383 p.transform.position.y,
3384 p.transform.position.z,
3385 )
3386 } else {
3387 (0.0, 0.0, 0.0)
3388 }
3389 }
3390
3391 pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
3392 let mut rows: Vec<(String, u32, String)> = self
3393 .inventory
3394 .iter()
3395 .filter(|(_, q)| **q > 0)
3396 .map(|(id, qty)| {
3397 let label = self
3398 .inventory_hints
3399 .get(id)
3400 .map(|h| h.display_name.clone())
3401 .unwrap_or_else(|| id.clone());
3402 (id.clone(), *qty, label)
3403 })
3404 .collect();
3405 rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
3406 rows
3407 }
3408
3409 pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
3410 self.inventory_hints
3411 .get(template_id)
3412 .map(|h| h.category.as_str())
3413 .filter(|c| !c.is_empty())
3414 }
3415
3416 pub fn stack_is_serving(stack: &flatland_protocol::ItemStack) -> bool {
3417 stack.props.get("serving").is_some_and(|v| v == "1")
3418 || Self::stack_is_liquid_vessel(stack)
3419 }
3420
3421 pub fn stack_is_liquid_vessel(stack: &flatland_protocol::ItemStack) -> bool {
3422 stack.props.get("liquid_vessel").is_some_and(|v| v == "1")
3423 || stack.props.get("serving_holds").is_some_and(|v| {
3424 v.split(',').any(|p| p.trim() == "liquid")
3425 })
3426 }
3427
3428 pub fn stack_is_food_serving(stack: &flatland_protocol::ItemStack) -> bool {
3429 stack.props.get("serving_holds").is_some_and(|v| {
3430 v.split(',').any(|p| p.trim() == "food")
3431 })
3432 }
3433
3434 pub fn stack_is_bulk_vessel(stack: &flatland_protocol::ItemStack) -> bool {
3435 stack.props.get("bulk_vessel").is_some_and(|v| v == "1")
3436 || stack.props.get("serving_holds").is_some_and(|v| {
3437 v.split(',').any(|p| p.trim() == "bulk")
3438 })
3439 }
3440
3441 pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
3442 stack
3443 .props
3444 .get("grants_item_status_effect")
3445 .map(|s| !s.is_empty())
3446 .unwrap_or(false)
3447 }
3448
3449 pub fn stack_is_blueprint_scroll(stack: &flatland_protocol::ItemStack) -> bool {
3450 stack
3451 .props
3452 .get("teaches_blueprint")
3453 .map(|s| !s.trim().is_empty())
3454 .unwrap_or(false)
3455 }
3456
3457 pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
3458 stack
3459 .props
3460 .get("grants_item_status_effect")
3461 .map(String::as_str)
3462 .filter(|s| !s.is_empty())
3463 }
3464
3465 pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
3466 stack
3467 .props
3468 .get("grants_item_status_mode")
3469 .map(String::as_str)
3470 .unwrap_or("on_hit")
3471 }
3472
3473 pub fn grant_target_options(
3475 &self,
3476 grant: &flatland_protocol::ItemStack,
3477 ) -> Vec<GrantTargetOption> {
3478 let mode = Self::grant_mode(grant);
3479 let grant_tags: Vec<&str> = grant
3480 .props
3481 .get("grants_item_status_tags")
3482 .map(|s| {
3483 s.split(',')
3484 .map(str::trim)
3485 .filter(|t| !t.is_empty())
3486 .collect()
3487 })
3488 .unwrap_or_default();
3489 let grant_id = grant.item_instance_id;
3490 let mut out = Vec::new();
3491 let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
3492 let Some(iid) = stack.item_instance_id else {
3493 return;
3494 };
3495 if Some(iid) == grant_id {
3496 return;
3497 }
3498 if stack.props.get("enchantable").map(String::as_str) == Some("0") {
3499 return;
3500 }
3501 if !grant_target_matches_mode(stack, mode) {
3502 return;
3503 }
3504 if !grant_tags_match(stack, &grant_tags) {
3505 return;
3506 }
3507 let name = stack
3508 .display_name
3509 .clone()
3510 .unwrap_or_else(|| stack.template_id.clone());
3511 let bindings = if stack.status_bindings.is_empty() {
3512 String::new()
3513 } else {
3514 format!(
3515 " · {}",
3516 stack
3517 .status_bindings
3518 .iter()
3519 .map(|b| b.effect_id.as_str())
3520 .collect::<Vec<_>>()
3521 .join(", ")
3522 )
3523 };
3524 out.push(GrantTargetOption {
3525 label: format!("{where_label}: {name}{bindings}"),
3526 target_instance_id: iid,
3527 });
3528 };
3529 fn walk(
3530 stacks: &[flatland_protocol::ItemStack],
3531 where_label: &str,
3532 push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
3533 ) {
3534 for s in stacks {
3535 push(s, where_label);
3536 if !s.contents.is_empty() {
3537 let nested = format!(
3538 "{where_label}/{}",
3539 s.display_name.as_deref().unwrap_or(s.template_id.as_str())
3540 );
3541 walk(&s.contents, &nested, push);
3542 }
3543 }
3544 }
3545 walk(&self.inventory_stacks, "Bag", &mut push);
3546 for (slot, stack) in &self.worn {
3547 push(stack, body_slot_label(*slot));
3548 let nest = format!(
3549 "{}/{}",
3550 body_slot_label(*slot),
3551 stack
3552 .display_name
3553 .as_deref()
3554 .unwrap_or(stack.template_id.as_str())
3555 );
3556 walk(&stack.contents, &nest, &mut push);
3557 }
3558 out
3559 }
3560
3561 pub fn item_base_mass(&self, template_id: &str) -> f32 {
3562 self.inventory_hints
3563 .get(template_id)
3564 .and_then(|h| h.base_mass)
3565 .unwrap_or(0.5)
3566 }
3567
3568 pub fn item_base_volume(&self, template_id: &str) -> f32 {
3569 self.inventory_hints
3570 .get(template_id)
3571 .and_then(|h| h.base_volume)
3572 .unwrap_or(1.0)
3573 }
3574
3575 pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
3576 let unit = stack
3577 .base_mass
3578 .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
3579 unit * stack.quantity as f32
3580 }
3581
3582 fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
3583 let unit = stack.base_volume.unwrap_or(1.0);
3584 unit * stack.quantity as f32
3585 + stack
3586 .contents
3587 .iter()
3588 .map(Self::stack_tree_volume)
3589 .sum::<f32>()
3590 }
3591
3592 fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
3593 contents.iter().map(Self::stack_tree_volume).sum()
3594 }
3595
3596 fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
3597 self.inventory_hints
3598 .get(template_id)
3599 .and_then(|h| h.capacity_volume)
3600 .filter(|c| *c > 0.0)
3601 }
3602
3603 fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
3604 stack
3605 .capacity_volume
3606 .filter(|c| *c > 0.0)
3607 .or_else(|| self.template_capacity_volume(&stack.template_id))
3608 }
3609
3610 pub fn container_volume_label(&self, row: &InventoryRow) -> String {
3612 let Some((used, cap)) = self.container_volume_stats(row) else {
3613 return String::new();
3614 };
3615 let free = (cap - used).max(0.0);
3616 format!(" vol {used:.0}/{cap:.0} ({free:.0} free)")
3617 }
3618
3619 fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
3620 if row.is_chest_shell {
3621 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
3622 return None;
3623 };
3624 let chest = self
3625 .placed_containers
3626 .iter()
3627 .find(|c| c.id == *container_id)?;
3628 let cap = self
3629 .stack_capacity_volume(&row.stack)
3630 .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
3631 let used = if chest.accessible {
3632 Self::contents_used_volume(&chest.contents)
3633 } else {
3634 0.0
3635 };
3636 return Some((used, cap));
3637 }
3638
3639 let cap = self.stack_capacity_volume(&row.stack)?;
3640 let used = Self::contents_used_volume(&row.stack.contents);
3641 Some((used, cap))
3642 }
3643
3644 pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
3645 if row.is_chest_shell {
3646 return true;
3647 }
3648 if row.is_equip_shell {
3649 return self.inventory_item_category(&row.stack.template_id) == Some("container");
3650 }
3651 self.inventory_item_category(&row.stack.template_id) == Some("container")
3652 || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
3653 }
3654
3655 fn container_stack_for(
3656 &self,
3657 location: &flatland_protocol::InventoryLocation,
3658 parent_instance_id: Option<uuid::Uuid>,
3659 ) -> Option<flatland_protocol::ItemStack> {
3660 match location {
3661 flatland_protocol::InventoryLocation::Root => {
3662 let pid = parent_instance_id?;
3663 self.find_stack_by_instance(&self.inventory_stacks, pid)
3664 }
3665 flatland_protocol::InventoryLocation::Worn { slot } => {
3666 let worn = self.worn.get(slot)?;
3667 if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
3668 Some(worn.clone())
3669 } else {
3670 self.find_stack_by_instance(&worn.contents, parent_instance_id?)
3671 }
3672 }
3673 flatland_protocol::InventoryLocation::Placed { container_id } => {
3674 let chest = self
3675 .placed_containers
3676 .iter()
3677 .find(|c| c.id == *container_id)?;
3678 if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
3679 Some(flatland_protocol::ItemStack {
3680 template_id: chest.template_id.clone(),
3681 quantity: 1,
3682 item_instance_id: chest.item_instance_id,
3683 props: Default::default(),
3684 status_bindings: Vec::new(),
3685 contents: chest.contents.clone(),
3686 display_name: Some(chest.display_name.clone()),
3687 category: Some("container".into()),
3688 capacity_volume: self
3689 .inventory_hints
3690 .get(&chest.template_id)
3691 .and_then(|h| h.capacity_volume),
3692 worker_lodging_capacity: chest.worker_lodging_capacity,
3693 ..Default::default()
3694 })
3695 } else {
3696 self.find_stack_by_instance(&chest.contents, parent_instance_id?)
3697 }
3698 }
3699 flatland_protocol::InventoryLocation::Keychain => None,
3700 flatland_protocol::InventoryLocation::WhisperPouch => None,
3701 }
3702 }
3703
3704 fn find_stack_by_instance(
3705 &self,
3706 stacks: &[flatland_protocol::ItemStack],
3707 instance_id: uuid::Uuid,
3708 ) -> Option<flatland_protocol::ItemStack> {
3709 for stack in stacks {
3710 if stack.item_instance_id == Some(instance_id) {
3711 return Some(stack.clone());
3712 }
3713 if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
3714 return Some(found);
3715 }
3716 }
3717 None
3718 }
3719
3720 pub fn max_movable_to(
3722 &self,
3723 template_id: &str,
3724 stack_qty: u32,
3725 from: &flatland_protocol::InventoryLocation,
3726 to: &flatland_protocol::InventoryLocation,
3727 parent_instance_id: Option<uuid::Uuid>,
3728 ) -> u32 {
3729 let unit_vol = self.item_base_volume(template_id);
3730 let mut limit = stack_qty;
3731
3732 if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
3733 let cap = parent
3734 .capacity_volume
3735 .or_else(|| {
3736 self.inventory_hints
3737 .get(&parent.template_id)
3738 .and_then(|h| h.capacity_volume)
3739 })
3740 .unwrap_or(0.0);
3741 if cap > 0.0 && unit_vol > 0.0 {
3742 let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
3743 limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
3744 }
3745 }
3746
3747 let _ = from;
3748 limit.max(0).min(stack_qty)
3749 }
3750
3751 pub fn move_picker_max_at_selection(&self) -> u32 {
3752 let Some(picker) = &self.move_picker else {
3753 return 1;
3754 };
3755 let Some(opt) = picker.options.get(self.move_picker_index) else {
3756 return picker.stack_quantity;
3757 };
3758 match &opt.kind {
3759 MoveOptionKind::Cancel
3760 | MoveOptionKind::Drop
3761 | MoveOptionKind::Use
3762 | MoveOptionKind::GrantApply
3763 | MoveOptionKind::SellPlotToCrown { .. }
3764 | MoveOptionKind::PickupPlaced { .. }
3765 | MoveOptionKind::RelocatePlaced { .. } => picker.stack_quantity,
3766 MoveOptionKind::Move {
3767 location,
3768 parent_instance_id,
3769 } => self.max_movable_to(
3770 &picker.template_id,
3771 picker.stack_quantity,
3772 &picker.from,
3773 location,
3774 *parent_instance_id,
3775 ),
3776 }
3777 }
3778
3779 pub fn clamp_move_picker_quantity(&mut self) {
3780 let max = self.move_picker_max_at_selection();
3781 if let Some(picker) = &mut self.move_picker {
3782 if max == 0 {
3783 picker.quantity = 1;
3784 } else {
3785 picker.quantity = picker.quantity.clamp(1, max);
3786 }
3787 }
3788 }
3789
3790 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
3791 let max = self.move_picker_max_at_selection().max(1);
3792 if let Some(picker) = &mut self.move_picker {
3793 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
3794 picker.quantity = next as u32;
3795 }
3796 }
3797
3798 pub fn move_picker_set_quantity_max(&mut self) {
3799 let max = self.move_picker_max_at_selection();
3800 if let Some(picker) = &mut self.move_picker {
3801 picker.quantity = if max == 0 {
3802 1
3803 } else {
3804 max.min(picker.stack_quantity)
3805 };
3806 }
3807 }
3808
3809 pub fn move_picker_set_quantity_min(&mut self) {
3810 if let Some(picker) = &mut self.move_picker {
3811 picker.quantity = 1;
3812 }
3813 }
3814
3815 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
3816 if let Some(picker) = &mut self.destroy_picker {
3817 let max = picker.stack_quantity.max(1);
3818 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
3819 picker.quantity = next as u32;
3820 }
3821 }
3822
3823 pub fn destroy_picker_set_quantity_max(&mut self) {
3824 if let Some(picker) = &mut self.destroy_picker {
3825 picker.quantity = picker.stack_quantity.max(1);
3826 }
3827 }
3828
3829 pub fn destroy_picker_set_quantity_min(&mut self) {
3830 if let Some(picker) = &mut self.destroy_picker {
3831 picker.quantity = 1;
3832 }
3833 }
3834
3835 pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
3836 let have = self.inventory.get(template_id).copied().unwrap_or(0);
3837 (have, have >= need)
3838 }
3839
3840 pub fn plot_build_stock_status(&self, template_id: &str, need: u32) -> (u32, bool) {
3842 let have = self
3843 .plot_build_offer
3844 .as_ref()
3845 .and_then(|o| {
3846 o.available
3847 .iter()
3848 .find(|s| s.template_id == template_id)
3849 .map(|s| s.quantity)
3850 })
3851 .unwrap_or_else(|| self.inventory.get(template_id).copied().unwrap_or(0));
3852 (have, have >= need)
3853 }
3854
3855 pub fn plot_build_wall_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3856 self.building_materials
3857 .iter()
3858 .filter(|m| m.can_wall)
3859 .collect()
3860 }
3861
3862 pub fn plot_build_roof_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3863 self.building_materials
3864 .iter()
3865 .filter(|m| m.can_roof)
3866 .collect()
3867 }
3868
3869 pub fn plot_build_selected_wall(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
3870 self.plot_build_wall_options()
3871 .get(self.plot_build_wall_index)
3872 .copied()
3873 }
3874
3875 pub fn plot_build_selected_roof(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
3876 self.plot_build_roof_options()
3877 .get(self.plot_build_roof_index)
3878 .copied()
3879 }
3880
3881 pub fn plot_build_bom_lines(&self) -> Vec<(String, String, u32)> {
3883 let Some(wall) = self.plot_build_selected_wall() else {
3884 return Vec::new();
3885 };
3886 let Some(roof) = self.plot_build_selected_roof() else {
3887 return Vec::new();
3888 };
3889 let area = self
3890 .plot_build_offer
3891 .as_ref()
3892 .filter(|o| o.pad_ok)
3893 .map(|o| o.pad_width_m * o.pad_depth_m)
3894 .unwrap_or(0.0);
3895 if area <= 0.0 {
3896 return Vec::new();
3897 }
3898 let mut map: std::collections::HashMap<String, (String, u32)> =
3899 std::collections::HashMap::new();
3900 for line in &wall.wall_bom {
3901 let qty = (area * line.per_m2).ceil().max(0.0) as u32;
3902 if qty == 0 {
3903 continue;
3904 }
3905 let name = if line.display_name.is_empty() {
3906 line.template_id.clone()
3907 } else {
3908 line.display_name.clone()
3909 };
3910 let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
3911 entry.1 = entry.1.saturating_add(qty);
3912 }
3913 for line in &roof.roof_bom {
3914 let qty = (area * line.per_m2).ceil().max(0.0) as u32;
3915 if qty == 0 {
3916 continue;
3917 }
3918 let name = if line.display_name.is_empty() {
3919 line.template_id.clone()
3920 } else {
3921 line.display_name.clone()
3922 };
3923 let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
3924 entry.1 = entry.1.saturating_add(qty);
3925 }
3926 let mut out: Vec<_> = map
3927 .into_iter()
3928 .map(|(id, (name, qty))| (id, name, qty))
3929 .collect();
3930 out.sort_by(|a, b| a.0.cmp(&b.0));
3931 out
3932 }
3933
3934 pub fn plot_build_duration_secs(&self) -> Option<f32> {
3935 let wall = self.plot_build_selected_wall()?;
3936 let roof = self.plot_build_selected_roof()?;
3937 let offer = self.plot_build_offer.as_ref()?;
3938 if !offer.pad_ok {
3939 return None;
3940 }
3941 let area = offer.pad_width_m * offer.pad_depth_m;
3942 let mult = wall.tick_mult.max(roof.tick_mult).max(0.1);
3943 let ticks = (offer.base_ticks as f32 + area * offer.tick_per_m2 as f32 * mult).ceil();
3944 Some(ticks.max(2.0) / 30.0)
3945 }
3946
3947 pub fn plot_build_can_afford(&self) -> bool {
3948 if self.plot_build_offer.as_ref().is_none_or(|o| !o.pad_ok) {
3949 return false;
3950 }
3951 self.plot_build_bom_lines()
3952 .iter()
3953 .all(|(id, _, need)| self.plot_build_stock_status(id, *need).1)
3954 }
3955
3956 pub fn currency_display(&self) -> String {
3957 crate::currency::currency_line(&self.inventory)
3958 }
3959
3960 pub fn in_shallow_water(&self) -> bool {
3962 let (px, py) = self.player_position();
3963 self.terrain_at(px, py)
3964 .is_some_and(|k| k == TerrainKindView::ShallowWater)
3965 }
3966
3967 pub fn near_liquid_fill_source(&self) -> bool {
3969 let (px, py) = self.player_position();
3970 const CELL: f32 = 1.0;
3971 let offsets = [
3972 (0.0, 0.0),
3973 (CELL, 0.0),
3974 (-CELL, 0.0),
3975 (0.0, CELL),
3976 (0.0, -CELL),
3977 ];
3978 for (dx, dy) in offsets {
3979 if matches!(
3980 self.terrain_at(px + dx, py + dy),
3981 Some(TerrainKindView::ShallowWater | TerrainKindView::DeepWater)
3982 ) {
3983 return true;
3984 }
3985 }
3986 self.buildings.iter().any(|b| {
3987 if !b.tags.iter().any(|t| t == "well") {
3988 return false;
3989 }
3990 let hw = b.width_m * 0.5;
3991 let hd = b.depth_m * 0.5;
3992 let nx = px.clamp(b.x - hw, b.x + hw);
3993 let ny = py.clamp(b.y - hd, b.y + hd);
3994 let dx = px - nx;
3995 let dy = py - ny;
3996 dx * dx + dy * dy <= INTERACTION_RADIUS_M * INTERACTION_RADIUS_M
3997 })
3998 }
3999
4000 pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
4001 self.terrain_zone_at(x, y).map(|z| z.kind)
4002 }
4003
4004 pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
4006 use std::cell::RefCell;
4007
4008 const CHUNK: i32 = 8;
4009 thread_local! {
4010 static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
4011 RefCell::new(None);
4012 }
4013
4014 let zones = &self.terrain_zones;
4015 if zones.is_empty() {
4016 return None;
4017 }
4018 if zones.len() <= 48 {
4019 return zones
4020 .iter()
4021 .enumerate()
4022 .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
4023 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
4024 .map(|(_, z)| z);
4025 }
4026
4027 let ptr = zones.as_ptr();
4028 let len = zones.len();
4029 INDEX.with(|cell| {
4030 let mut slot = cell.borrow_mut();
4031 let stale = match slot.as_ref() {
4032 Some((p, l, _)) => *p != ptr || *l != len,
4033 None => true,
4034 };
4035 if stale {
4036 let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
4037 std::collections::HashMap::new();
4038 for (zi, z) in zones.iter().enumerate() {
4039 let x0 = z.x0.min(z.x1).floor() as i32;
4040 let y0 = z.y0.min(z.y1).floor() as i32;
4041 let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
4042 let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
4043 let cx0 = x0.div_euclid(CHUNK);
4044 let cy0 = y0.div_euclid(CHUNK);
4045 let cx1 = x1.div_euclid(CHUNK);
4046 let cy1 = y1.div_euclid(CHUNK);
4047 for cy in cy0..=cy1 {
4048 for cx in cx0..=cx1 {
4049 chunks.entry((cx, cy)).or_default().push(zi);
4050 }
4051 }
4052 }
4053 *slot = Some((ptr, len, chunks));
4054 }
4055 let chunks = &slot.as_ref().expect("index").2;
4056 let cx = (x.floor() as i32).div_euclid(CHUNK);
4057 let cy = (y.floor() as i32).div_euclid(CHUNK);
4058 let mut best: Option<(usize, &TerrainZoneView)> = None;
4059 if let Some(list) = chunks.get(&(cx, cy)) {
4060 for &zi in list {
4061 let Some(z) = zones.get(zi) else { continue };
4062 if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
4063 continue;
4064 }
4065 best = match best {
4066 None => Some((zi, z)),
4067 Some((bi, bz)) => {
4068 if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
4069 Some((zi, z))
4070 } else {
4071 Some((bi, bz))
4072 }
4073 }
4074 };
4075 }
4076 }
4077 best.map(|(_, z)| z)
4078 })
4079 }
4080
4081 pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
4083 self.terrain_zone_at(x, y)
4084 .map(|z| z.elevation)
4085 .unwrap_or(0.0)
4086 }
4087
4088 pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
4090 const TOL: f32 = 0.35;
4091 let mut levels = vec![self.elevation_at(x, y)];
4092 for p in &self.z_platforms {
4093 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
4094 levels.push(p.z);
4095 }
4096 }
4097 levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
4098 levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
4099 levels
4100 }
4101
4102 pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
4103 const TOL: f32 = 0.35;
4104 self.walkable_levels_at(x, y)
4105 .iter()
4106 .any(|&l| (l - z).abs() <= TOL)
4107 }
4108
4109 pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
4110 let mut top = self.elevation_at(x, y);
4111 for p in &self.z_platforms {
4112 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
4113 top = top.max(p.z);
4114 }
4115 }
4116 top
4117 }
4118
4119 pub fn effective_inside_building(&self) -> Option<String> {
4121 self.player_entity().and_then(|p| p.inside_building.clone())
4122 }
4123
4124 pub fn placed_container_in_current_space(
4128 &self,
4129 c: &flatland_protocol::PlacedContainerView,
4130 ) -> bool {
4131 match (
4132 self.effective_inside_building().as_deref(),
4133 c.building_id.as_deref(),
4134 ) {
4135 (None, None) => true,
4136 (Some(a), Some(b)) => a == b,
4137 _ => false,
4138 }
4139 }
4140
4141 fn merge_stack_catalog_hints(&mut self, stacks: &[flatland_protocol::ItemStack]) {
4142 fn walk(
4143 stacks: &[flatland_protocol::ItemStack],
4144 hints: &mut std::collections::HashMap<String, InventoryHint>,
4145 ) {
4146 for stack in stacks {
4147 if stack.display_name.is_some()
4148 || stack.category.is_some()
4149 || stack.base_mass.is_some()
4150 || stack.base_volume.is_some()
4151 || stack.base_value_copper.is_some()
4152 {
4153 hints.insert(
4154 stack.template_id.clone(),
4155 InventoryHint {
4156 display_name: stack
4157 .display_name
4158 .clone()
4159 .unwrap_or_else(|| stack.template_id.clone()),
4160 category: stack.category.clone().unwrap_or_default(),
4161 base_mass: stack.base_mass,
4162 base_volume: stack.base_volume,
4163 capacity_volume: stack.capacity_volume,
4164 stackable: stack.stackable.unwrap_or(true),
4165 listable: stack.listable.unwrap_or_else(|| {
4166 category_default_listable(stack.category.as_deref().unwrap_or(""))
4167 }),
4168 base_value_copper: stack.base_value_copper,
4169 },
4170 );
4171 }
4172 walk(&stack.contents, hints);
4173 }
4174 }
4175 walk(stacks, &mut self.inventory_hints);
4176 }
4177
4178 pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
4179 self.inventory_stacks = stacks.to_vec();
4180 self.inventory.clear();
4181 self.inventory_hints.clear();
4182 fn walk(
4183 stacks: &[flatland_protocol::ItemStack],
4184 inventory: &mut std::collections::HashMap<String, u32>,
4185 hints: &mut std::collections::HashMap<String, InventoryHint>,
4186 ) {
4187 for stack in stacks {
4188 *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
4189 if stack.display_name.is_some()
4190 || stack.category.is_some()
4191 || stack.base_mass.is_some()
4192 || stack.base_volume.is_some()
4193 || stack.base_value_copper.is_some()
4194 {
4195 hints.insert(
4196 stack.template_id.clone(),
4197 InventoryHint {
4198 display_name: stack
4199 .display_name
4200 .clone()
4201 .unwrap_or_else(|| stack.template_id.clone()),
4202 category: stack.category.clone().unwrap_or_default(),
4203 base_mass: stack.base_mass,
4204 base_volume: stack.base_volume,
4205 capacity_volume: stack.capacity_volume,
4206 stackable: stack.stackable.unwrap_or(true),
4207 listable: stack.listable.unwrap_or_else(|| {
4208 category_default_listable(stack.category.as_deref().unwrap_or(""))
4209 }),
4210 base_value_copper: stack.base_value_copper,
4211 },
4212 );
4213 }
4214 walk(&stack.contents, inventory, hints);
4215 }
4216 }
4217 walk(stacks, &mut self.inventory, &mut self.inventory_hints);
4218 for item in self.worn.values() {
4220 walk(
4221 std::slice::from_ref(item),
4222 &mut self.inventory,
4223 &mut self.inventory_hints,
4224 );
4225 }
4226 }
4227
4228 fn sync_item_catalog(&mut self, entries: &[ItemCatalogEntryView]) {
4229 if entries.is_empty() {
4230 return;
4231 }
4232 self.item_catalog.clear();
4233 self.item_catalog.reserve(entries.len());
4234 for entry in entries {
4235 if entry.template_id.is_empty() {
4236 continue;
4237 }
4238 self.item_catalog
4239 .insert(entry.template_id.clone(), entry.clone());
4240 }
4241 }
4242
4243 fn remove_carried_instance(&mut self, instance_id: uuid::Uuid, quantity: Option<u32>) {
4247 fn take_from(
4248 stacks: &mut Vec<flatland_protocol::ItemStack>,
4249 instance_id: uuid::Uuid,
4250 qty: Option<u32>,
4251 ) -> bool {
4252 if let Some(i) = stacks
4253 .iter()
4254 .position(|s| s.item_instance_id == Some(instance_id))
4255 {
4256 let have = stacks[i].quantity;
4257 let take = qty.unwrap_or(have).min(have);
4258 if take >= have {
4259 stacks.remove(i);
4260 } else {
4261 stacks[i].quantity = have - take;
4262 }
4263 return true;
4264 }
4265 stacks
4266 .iter_mut()
4267 .any(|stack| take_from(&mut stack.contents, instance_id, qty))
4268 }
4269
4270 if take_from(&mut self.inventory_stacks, instance_id, quantity) {
4271 let stacks = self.inventory_stacks.clone();
4272 self.sync_inventory_from_stacks(&stacks);
4273 self.refresh_inventory_ui();
4274 return;
4275 }
4276 let slots: Vec<_> = self.worn.keys().copied().collect();
4277 for slot in slots {
4278 let Some(item) = self.worn.get_mut(&slot) else {
4279 continue;
4280 };
4281 if take_from(&mut item.contents, instance_id, quantity) {
4282 let stacks = self.inventory_stacks.clone();
4283 self.sync_inventory_from_stacks(&stacks);
4284 self.refresh_inventory_ui();
4285 return;
4286 }
4287 }
4288 }
4289
4290 pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
4293 if notice.message.starts_with("Gave ") {
4297 if notice.coins_delta != 0 {
4298 crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
4299 let stacks = self.inventory_stacks.clone();
4300 self.sync_inventory_from_stacks(&stacks);
4301 }
4302 self.record_shop_trade_notice(notice);
4303 return;
4304 }
4305 let subtract_items =
4306 notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
4307 for stack in ¬ice.inventory_delta {
4308 if stack.quantity == 0 {
4309 continue;
4310 }
4311 if subtract_items {
4312 crate::currency::drain_template_stacks(
4313 &mut self.inventory_stacks,
4314 &stack.template_id,
4315 stack.quantity,
4316 );
4317 continue;
4318 }
4319 let stackable = self
4320 .inventory_hints
4321 .get(&stack.template_id)
4322 .map(|h| h.stackable)
4323 .or(stack.stackable)
4324 .unwrap_or(true);
4325 if stackable {
4326 if let Some(existing) = self
4327 .inventory_stacks
4328 .iter_mut()
4329 .find(|s| s.template_id == stack.template_id)
4330 {
4331 existing.quantity = existing.quantity.saturating_add(stack.quantity);
4332 if stack.display_name.is_some() {
4333 existing.display_name = stack.display_name.clone();
4334 }
4335 if stack.category.is_some() {
4336 existing.category = stack.category.clone();
4337 }
4338 continue;
4339 }
4340 }
4341 self.inventory_stacks.push(stack.clone());
4342 }
4343 if notice.coins_delta != 0 {
4344 crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
4345 }
4346 if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
4347 let stacks = self.inventory_stacks.clone();
4348 self.sync_inventory_from_stacks(&stacks);
4349 }
4350 self.record_shop_trade_notice(notice);
4351 }
4352
4353 pub fn worn_rows(&self) -> Vec<InventoryRow> {
4358 let mut rows = Vec::new();
4359 for (slot, item) in &self.worn {
4360 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4361 rows.push(InventoryRow {
4362 depth: 0,
4363 stack: item.clone(),
4364 from: from.clone(),
4365 from_parent_instance_id: None,
4366 is_equip_shell: true,
4367 is_chest_shell: false,
4368 section: InventorySection::Worn,
4369 });
4370 for child in &item.contents {
4371 push_inventory_rows(
4372 &mut rows,
4373 1,
4374 child,
4375 &from,
4376 item.item_instance_id,
4377 InventorySection::Worn,
4378 );
4379 }
4380 }
4381 rows
4382 }
4383
4384 pub fn trade_presentable_stacks(&self) -> Vec<&flatland_protocol::ItemStack> {
4386 let equipped = self.hand_equipped_instance_ids();
4387 self.inventory_stacks
4388 .iter()
4389 .filter(|s| s.item_instance_id.is_some_and(|id| !equipped.contains(&id)))
4390 .collect()
4391 }
4392
4393 pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
4395 let equipped = self.hand_equipped_instance_ids();
4396 self.inventory_stacks
4397 .iter()
4398 .filter_map(|stack| {
4399 let item_instance_id = stack.item_instance_id?;
4400 if equipped.contains(&item_instance_id) {
4401 return None;
4402 }
4403 let label = stack
4404 .display_name
4405 .clone()
4406 .unwrap_or_else(|| stack.template_id.clone());
4407 let label = if stack.quantity > 1 {
4408 format!("{label} ×{}", stack.quantity)
4409 } else {
4410 label
4411 };
4412 Some(WorkerGiveOption {
4413 item_instance_id,
4414 label,
4415 quantity: stack.quantity,
4416 template_id: stack.template_id.clone(),
4417 })
4418 })
4419 .collect()
4420 }
4421
4422 pub fn teachable_blueprint_options(
4424 &self,
4425 worker: &flatland_protocol::HiredWorkerView,
4426 ) -> Vec<WorkerTeachOption> {
4427 let copper = crate::currency::copper_from_counts(&self.inventory);
4428 let mut options: Vec<WorkerTeachOption> = self
4429 .blueprints
4430 .iter()
4431 .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
4432 .map(|bp| {
4433 let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
4434 let cost = bp.worker_train_copper;
4435 WorkerTeachOption {
4436 blueprint_id: bp.id.clone(),
4437 label: if bp.label.is_empty() {
4438 bp.id.clone()
4439 } else {
4440 bp.label.clone()
4441 },
4442 cost_copper: cost,
4443 min_level,
4444 worker_level: worker.level,
4445 can_afford: copper >= cost,
4446 level_ok: worker.level >= min_level,
4447 }
4448 })
4449 .collect();
4450 options.sort_by(|a, b| a.label.cmp(&b.label));
4451 options
4452 }
4453
4454 pub fn person_rows(&self) -> Vec<InventoryRow> {
4457 self.person_rows_filtered("")
4458 }
4459
4460 pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4461 let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
4462 roots.sort_by(|a, b| {
4463 let ca = a
4464 .category
4465 .as_deref()
4466 .or_else(|| self.inventory_item_category(&a.template_id))
4467 .unwrap_or("");
4468 let cb = b
4469 .category
4470 .as_deref()
4471 .or_else(|| self.inventory_item_category(&b.template_id))
4472 .unwrap_or("");
4473 let ga = inventory_category_group(ca).1;
4474 let gb = inventory_category_group(cb).1;
4475 ga.cmp(&gb).then_with(|| {
4476 let na = a.display_name.as_deref().unwrap_or(a.template_id.as_str());
4477 let nb = b.display_name.as_deref().unwrap_or(b.template_id.as_str());
4478 na.cmp(nb)
4479 })
4480 });
4481 let mut rows = Vec::new();
4482 for stack in roots {
4483 push_inventory_rows_filtered(
4484 &mut rows,
4485 0,
4486 stack,
4487 &flatland_protocol::InventoryLocation::Root,
4488 None,
4489 InventorySection::Person,
4490 filter,
4491 );
4492 }
4493 rows
4494 }
4495
4496 pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4497 if filter.is_empty() {
4498 return self.worn_rows();
4499 }
4500 let mut rows = Vec::new();
4501 for (slot, item) in &self.worn {
4502 if !stack_matches_filter(item, filter) {
4503 continue;
4504 }
4505 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4506 let self_hit = {
4507 let f = filter.to_ascii_lowercase();
4508 let name = item
4509 .display_name
4510 .as_deref()
4511 .unwrap_or("")
4512 .to_ascii_lowercase();
4513 let tid = item.template_id.to_ascii_lowercase();
4514 name.contains(&f) || tid.contains(&f)
4515 };
4516 rows.push(InventoryRow {
4517 depth: 0,
4518 stack: item.clone(),
4519 from: from.clone(),
4520 from_parent_instance_id: None,
4521 is_equip_shell: true,
4522 is_chest_shell: false,
4523 section: InventorySection::Worn,
4524 });
4525 for child in &item.contents {
4526 if self_hit || stack_matches_filter(child, filter) {
4527 push_inventory_rows_filtered(
4528 &mut rows,
4529 1,
4530 child,
4531 &from,
4532 item.item_instance_id,
4533 InventorySection::Worn,
4534 if self_hit { "" } else { filter },
4535 );
4536 }
4537 }
4538 }
4539 rows
4540 }
4541
4542 pub fn carried_worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4546 let mut rows = Vec::new();
4547 for (slot, item) in &self.worn {
4548 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4549 for child in &item.contents {
4550 push_inventory_rows_filtered(
4551 &mut rows,
4552 0,
4553 child,
4554 &from,
4555 item.item_instance_id,
4556 InventorySection::Person,
4557 filter,
4558 );
4559 }
4560 }
4561 rows
4562 }
4563
4564 pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
4566 let mut rows = self.worn_rows();
4567 rows.extend(self.person_rows());
4568 rows.into_iter().map(|r| (r.depth, r.stack)).collect()
4569 }
4570
4571 pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
4575 let (px, py) = self.player_position();
4576 let mut list: Vec<NearbyContainer> = self
4577 .placed_containers
4578 .iter()
4579 .filter(|c| self.placed_container_in_current_space(c))
4580 .filter_map(|c| {
4581 let distance_m = (c.x - px).hypot(c.y - py);
4582 if distance_m > CONTAINER_RANGE_M {
4583 return None;
4584 }
4585 let mut rows = Vec::new();
4586 let from = flatland_protocol::InventoryLocation::Placed {
4587 container_id: c.id.clone(),
4588 };
4589 rows.push(InventoryRow {
4590 depth: 0,
4591 stack: flatland_protocol::ItemStack {
4592 template_id: c.template_id.clone(),
4593 quantity: 1,
4594 item_instance_id: c.item_instance_id,
4595 props: Default::default(),
4596 status_bindings: Vec::new(),
4597 contents: Vec::new(),
4598 display_name: Some(c.display_name.clone()),
4599 category: Some("container".into()),
4600 capacity_volume: c.capacity_volume,
4601 worker_lodging_capacity: c.worker_lodging_capacity,
4602 ..Default::default()
4603 },
4604 from: from.clone(),
4605 from_parent_instance_id: None,
4606 is_equip_shell: false,
4607 is_chest_shell: true,
4608 section: InventorySection::Nearby,
4609 });
4610 if c.accessible {
4611 for child in &c.contents {
4612 push_inventory_rows(
4613 &mut rows,
4614 1,
4615 child,
4616 &from,
4617 c.item_instance_id,
4618 InventorySection::Nearby,
4619 );
4620 }
4621 }
4622 Some(NearbyContainer {
4623 view: c.clone(),
4624 distance_m,
4625 rows,
4626 })
4627 })
4628 .collect();
4629 list.sort_by(|a, b| {
4630 a.distance_m
4631 .partial_cmp(&b.distance_m)
4632 .unwrap_or(std::cmp::Ordering::Equal)
4633 });
4634 list
4635 }
4636
4637 pub fn nearest_placed_container(
4639 &self,
4640 max_dist: f32,
4641 ) -> Option<flatland_protocol::PlacedContainerView> {
4642 let (px, py) = self.player_position();
4643 self.placed_containers
4644 .iter()
4645 .filter(|c| self.placed_container_in_current_space(c))
4646 .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
4647 .min_by(|a, b| {
4648 let da = (a.x - px).hypot(a.y - py);
4649 let db = (b.x - px).hypot(b.y - py);
4650 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
4651 })
4652 .cloned()
4653 }
4654
4655 pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
4658 let filter = self.inventory_filter.as_str();
4659 match self.inventory_tab {
4660 InventoryTab::OnPerson => {
4661 let mut rows = self.carried_worn_rows_filtered(filter);
4662 rows.extend(self.person_rows_filtered(filter));
4663 rows
4664 }
4665 InventoryTab::Nearby => {
4666 let mut rows = Vec::new();
4667 for nc in self.nearby_containers() {
4668 if filter.is_empty() {
4669 rows.extend(nc.rows);
4670 continue;
4671 }
4672 let shell = nc.rows.first().cloned();
4673 let contents: Vec<_> = nc
4674 .rows
4675 .iter()
4676 .skip(1)
4677 .filter(|r| stack_matches_filter(&r.stack, filter))
4678 .cloned()
4679 .collect();
4680 let shell_hit = shell
4681 .as_ref()
4682 .map(|s| stack_matches_filter(&s.stack, filter))
4683 .unwrap_or(false);
4684 if shell_hit || !contents.is_empty() {
4685 if let Some(s) = shell {
4686 rows.push(s);
4687 }
4688 if shell_hit {
4689 rows.extend(nc.rows.into_iter().skip(1));
4690 } else {
4691 rows.extend(contents);
4692 }
4693 }
4694 }
4695 rows
4696 }
4697 }
4698 }
4699
4700 pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
4701 self.inventory_selectable_rows()
4702 .into_iter()
4703 .nth(self.inventory_menu_index)
4704 }
4705
4706 fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
4707 let cat = self
4708 .inventory_item_category(&row.stack.template_id)
4709 .unwrap_or("");
4710 if cat == "key" {
4711 self.key_inventory_label(&row.stack)
4712 } else {
4713 row.stack
4714 .display_name
4715 .clone()
4716 .unwrap_or_else(|| row.stack.template_id.clone())
4717 }
4718 }
4719
4720 fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
4722 let bindings =
4723 format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
4724 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
4725 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
4726 let mode = Self::grant_mode(&row.stack);
4727 format!(" [grant {effect} · {mode} — e apply]")
4728 } else {
4729 String::new()
4730 };
4731 let qty = if row.stack.quantity > 1 {
4732 format!(" ×{}", row.stack.quantity)
4733 } else {
4734 String::new()
4735 };
4736 let worn_slot = if row.is_equip_shell {
4737 match row.from {
4738 flatland_protocol::InventoryLocation::Worn { slot } => {
4739 format!(" ({})", body_slot_label(slot))
4740 }
4741 _ => String::new(),
4742 }
4743 } else {
4744 String::new()
4745 };
4746 format!("{grant_hint}{bindings}{qty}{worn_slot}")
4747 }
4748
4749 fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
4750 (
4751 row.stack.template_id.clone(),
4752 self.inventory_row_base_label(row),
4753 self.inventory_row_visible_mod_signature(row),
4754 )
4755 }
4756
4757 fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
4759 let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
4760 for row in self.inventory_selectable_rows() {
4761 if row.stack.item_instance_id.is_none() {
4762 continue;
4763 }
4764 let key = self.inventory_row_instance_identity_key(&row);
4765 *counts.entry(key).or_default() += 1;
4766 }
4767 counts
4768 .into_iter()
4769 .filter(|(_, n)| *n > 1)
4770 .map(|(k, _)| k)
4771 .collect()
4772 }
4773
4774 fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
4775 let hex: String = id
4776 .as_simple()
4777 .to_string()
4778 .chars()
4779 .filter(|c| c.is_ascii_hexdigit())
4780 .collect();
4781 let short = if hex.len() >= 4 {
4782 &hex[hex.len() - 4..]
4783 } else {
4784 hex.as_str()
4785 };
4786 format!("Instance {id} (#{short})")
4787 }
4788
4789 pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
4791 let cat = self
4792 .inventory_item_category(&row.stack.template_id)
4793 .unwrap_or("");
4794 let label = self.inventory_row_base_label(row);
4795 let hint: String = if row.is_equip_shell {
4796 " [worn — Enter to unequip]".into()
4797 } else if row.is_chest_shell {
4798 let (locked, lodging_note) = match &row.from {
4799 flatland_protocol::InventoryLocation::Placed { container_id } => {
4800 let locked = self
4801 .placed_containers
4802 .iter()
4803 .find(|c| c.id == *container_id)
4804 .map(|c| c.locked)
4805 .unwrap_or(false);
4806 let lodging_note = self
4807 .lodging_occupancy_label(container_id)
4808 .map(|who| format!(" [lodging: {who}]"))
4809 .unwrap_or_default();
4810 (locked, lodging_note)
4811 }
4812 _ => (false, String::new()),
4813 };
4814 if locked {
4815 format!(" [locked — Enter pick up · l unlock]{lodging_note}")
4816 } else {
4817 format!(" [Enter pick up · l lock]{lodging_note}")
4818 }
4819 } else if cat == "key" {
4820 self.key_inventory_hint(&row.stack)
4821 } else {
4822 match cat {
4823 "weapon" => " [weapon]".into(),
4824 "container" => " [bag/chest/belt]".into(),
4825 "lodging" => " [worker lodging]".into(),
4826 "armor" => " [armor]".into(),
4827 _ => String::new(),
4828 }
4829 };
4830 let qty = if row.stack.quantity > 1 {
4831 format!(" ×{}", row.stack.quantity)
4832 } else {
4833 String::new()
4834 };
4835 let bindings =
4836 format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
4837 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
4838 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
4839 let mode = Self::grant_mode(&row.stack);
4840 format!(" [grant {effect} · {mode} — e apply]")
4841 } else {
4842 String::new()
4843 };
4844 let mass = self.stack_mass(&row.stack);
4845 let mass_kg = (mass >= 0.05).then_some(mass);
4846 let mass_str = mass_kg.map(|m| format!(" {m:.1} kg")).unwrap_or_default();
4847 let volume = self.container_volume_stats(row);
4848 let vol_str = self.container_volume_label(row);
4849
4850 let mut title = label.clone();
4851 title.push_str(&qty);
4852 if row.is_equip_shell {
4853 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
4854 title.push_str(&format!(" ({})", body_slot_label(slot)));
4855 }
4856 }
4857
4858 InventoryRowView {
4859 depth: row.depth,
4860 text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
4861 title: format!("{title}{grant_hint}{bindings}"),
4862 mass_kg,
4863 volume,
4864 instance_tooltip: None,
4865 }
4866 }
4867
4868 fn push_browser_item(
4869 &self,
4870 lines: &mut Vec<InventoryBrowserLine>,
4871 row: &InventoryRow,
4872 global_idx: &mut usize,
4873 target: usize,
4874 highlight: bool,
4875 ambiguous_instance_keys: &HashSet<(String, String, String)>,
4876 ) {
4877 let mut view = self.format_inventory_row(row);
4878 if let Some(id) = row.stack.item_instance_id {
4879 let key = self.inventory_row_instance_identity_key(row);
4880 if ambiguous_instance_keys.contains(&key) {
4881 view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
4882 }
4883 }
4884 lines.push(InventoryBrowserLine::Item {
4885 selectable_index: *global_idx,
4886 selected: highlight && *global_idx == target,
4887 depth: view.depth,
4888 text: view.text,
4889 title: view.title,
4890 mass_kg: view.mass_kg,
4891 volume: view.volume,
4892 instance_tooltip: view.instance_tooltip,
4893 });
4894 *global_idx += 1;
4895 }
4896
4897 pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
4900 let mut lines = Vec::new();
4901 let target = self.inventory_menu_index;
4902 let highlight = !self.show_move_picker && !self.show_grant_picker;
4903 let filter = self.inventory_filter.as_str();
4904 let mut global_idx = 0usize;
4905 let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
4906
4907 match self.inventory_tab {
4908 InventoryTab::OnPerson => {
4909 lines.push(InventoryBrowserLine::Section("— In carried bags —".into()));
4910 let carried = self.carried_worn_rows_filtered(filter);
4911 if carried.is_empty() {
4912 lines.push(InventoryBrowserLine::Hint(
4913 " (no items in carried bags)".into(),
4914 ));
4915 } else {
4916 for row in &carried {
4917 self.push_browser_item(
4918 &mut lines,
4919 row,
4920 &mut global_idx,
4921 target,
4922 highlight,
4923 &ambiguous_instance_keys,
4924 );
4925 }
4926 }
4927
4928 lines.push(InventoryBrowserLine::Blank);
4929 lines.push(InventoryBrowserLine::Section(
4930 "— On you (loose, not worn) —".into(),
4931 ));
4932 let person = self.person_rows_filtered(filter);
4933 if person.is_empty() {
4934 lines.push(InventoryBrowserLine::Hint(" (empty)".into()));
4935 } else {
4936 let mut last_group: Option<&'static str> = None;
4937 for row in &person {
4938 if row.depth == 0 {
4939 let cat = row
4940 .stack
4941 .category
4942 .as_deref()
4943 .or_else(|| self.inventory_item_category(&row.stack.template_id))
4944 .unwrap_or("");
4945 let (group, _) = inventory_category_group(cat);
4946 if last_group != Some(group) {
4947 lines.push(InventoryBrowserLine::SlotLabel(format!(" {group}")));
4948 last_group = Some(group);
4949 }
4950 }
4951 self.push_browser_item(
4952 &mut lines,
4953 row,
4954 &mut global_idx,
4955 target,
4956 highlight,
4957 &ambiguous_instance_keys,
4958 );
4959 }
4960 }
4961 }
4962 InventoryTab::Nearby => {
4963 let nearby = self.nearby_containers();
4964 if nearby.is_empty() {
4965 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
4966 lines.push(InventoryBrowserLine::Hint(
4967 " (none within reach — walk up to a chest)".into(),
4968 ));
4969 lines.push(InventoryBrowserLine::Hint(
4970 " Select an on-person item, then m / Enter → move into chest.".into(),
4971 ));
4972 } else {
4973 let mut any_visible = false;
4974 for nc in &nearby {
4975 let shell = nc.rows.first();
4976 let contents: Vec<&InventoryRow> = if filter.is_empty() {
4977 nc.rows.iter().skip(1).collect()
4978 } else {
4979 let shell_hit = shell
4980 .map(|s| {
4981 let f = filter.to_ascii_lowercase();
4982 let name = s
4983 .stack
4984 .display_name
4985 .as_deref()
4986 .unwrap_or("")
4987 .to_ascii_lowercase();
4988 let tid = s.stack.template_id.to_ascii_lowercase();
4989 name.contains(&f) || tid.contains(&f)
4990 })
4991 .unwrap_or(false);
4992 if shell_hit {
4993 nc.rows.iter().skip(1).collect()
4994 } else {
4995 nc.rows
4996 .iter()
4997 .skip(1)
4998 .filter(|r| stack_matches_filter(&r.stack, filter))
4999 .collect()
5000 }
5001 };
5002 let shell_visible = filter.is_empty()
5003 || shell
5004 .map(|s| stack_matches_filter(&s.stack, filter))
5005 .unwrap_or(false)
5006 || !contents.is_empty();
5007 if !shell_visible && shell.is_some() {
5008 continue;
5009 }
5010 any_visible = true;
5011 lines.push(InventoryBrowserLine::Blank);
5012 let lock_note = if nc.view.locked && nc.view.accessible {
5013 " unlocked with your key"
5014 } else if nc.view.locked {
5015 " locked"
5016 } else {
5017 ""
5018 };
5019 lines.push(InventoryBrowserLine::Section(format!(
5020 "— {} ({:.0}m away){lock_note} —",
5021 nc.view.display_name, nc.distance_m
5022 )));
5023 if !nc.view.accessible {
5024 lines.push(InventoryBrowserLine::Hint(
5025 " locked — need the matching key (l to try)".into(),
5026 ));
5027 } else if nc.rows.is_empty() {
5028 lines.push(InventoryBrowserLine::Hint(
5029 " (empty — switch to On person, select an item, m to move in)"
5030 .into(),
5031 ));
5032 } else if let Some(shell_row) = shell {
5033 self.push_browser_item(
5034 &mut lines,
5035 shell_row,
5036 &mut global_idx,
5037 target,
5038 highlight,
5039 &ambiguous_instance_keys,
5040 );
5041 for row in contents {
5042 self.push_browser_item(
5043 &mut lines,
5044 row,
5045 &mut global_idx,
5046 target,
5047 highlight,
5048 &ambiguous_instance_keys,
5049 );
5050 }
5051 }
5052 }
5053 if !any_visible {
5054 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
5055 lines.push(InventoryBrowserLine::Hint(
5056 " (no matching items — clear filter with Esc)".into(),
5057 ));
5058 }
5059 }
5060 }
5061 }
5062 lines
5063 }
5064
5065 pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
5067 let mut opts = Vec::new();
5068 opts.push(MoveOption {
5069 label: "Relocate…".into(),
5070 kind: MoveOptionKind::RelocatePlaced {
5071 container_id: container_id.to_string(),
5072 },
5073 });
5074 opts.push(MoveOption {
5075 label: "On your person (loose)".into(),
5076 kind: MoveOptionKind::PickupPlaced {
5077 container_id: container_id.to_string(),
5078 nest_location: flatland_protocol::InventoryLocation::Root,
5079 nest_parent_instance_id: None,
5080 },
5081 });
5082 for (slot, item) in &self.worn {
5083 if item.category.as_deref() != Some("container") {
5084 continue;
5085 }
5086 if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
5087 continue;
5088 }
5089 let Some(parent_id) = item.item_instance_id else {
5090 continue;
5091 };
5092 let shell_name = item
5093 .display_name
5094 .clone()
5095 .unwrap_or_else(|| item.template_id.clone());
5096 opts.push(MoveOption {
5097 label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
5098 kind: MoveOptionKind::PickupPlaced {
5099 container_id: container_id.to_string(),
5100 nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
5101 nest_parent_instance_id: Some(parent_id),
5102 },
5103 });
5104 Self::append_chest_pickup_nested(
5106 &mut opts,
5107 container_id,
5108 flatland_protocol::InventoryLocation::Worn { slot: *slot },
5109 item,
5110 &format!("in {shell_name}"),
5111 );
5112 }
5113 opts.push(MoveOption {
5114 label: "Cancel".into(),
5115 kind: MoveOptionKind::Cancel,
5116 });
5117 opts
5118 }
5119
5120 fn append_chest_pickup_nested(
5121 opts: &mut Vec<MoveOption>,
5122 container_id: &str,
5123 location: flatland_protocol::InventoryLocation,
5124 parent: &flatland_protocol::ItemStack,
5125 context: &str,
5126 ) {
5127 for child in &parent.contents {
5128 if child.category.as_deref() != Some("container") {
5129 continue;
5130 }
5131 if !Self::is_volume_container_stack(child) {
5132 continue;
5133 }
5134 if child.world_placeable == Some(true) {
5136 continue;
5137 }
5138 let Some(child_id) = child.item_instance_id else {
5139 continue;
5140 };
5141 let name = child
5142 .display_name
5143 .clone()
5144 .unwrap_or_else(|| child.template_id.clone());
5145 opts.push(MoveOption {
5146 label: format!("{name} ({context})"),
5147 kind: MoveOptionKind::PickupPlaced {
5148 container_id: container_id.to_string(),
5149 nest_location: location.clone(),
5150 nest_parent_instance_id: Some(child_id),
5151 },
5152 });
5153 Self::append_chest_pickup_nested(
5154 opts,
5155 container_id,
5156 location.clone(),
5157 child,
5158 &format!("in {name}"),
5159 );
5160 }
5161 }
5162
5163 pub fn move_destinations_for(
5165 &self,
5166 from: &flatland_protocol::InventoryLocation,
5167 from_parent_instance_id: Option<uuid::Uuid>,
5168 moving_instance_id: Option<uuid::Uuid>,
5169 moving_template_id: &str,
5170 ) -> Vec<MoveOption> {
5171 let mut opts = Vec::new();
5172 if *from != flatland_protocol::InventoryLocation::Root {
5173 opts.push(MoveOption {
5174 label: "On your person (loose)".into(),
5175 kind: MoveOptionKind::Move {
5176 location: flatland_protocol::InventoryLocation::Root,
5177 parent_instance_id: None,
5178 },
5179 });
5180 }
5181 for (slot, item) in &self.worn {
5182 if item.category.as_deref() != Some("container") {
5183 continue;
5184 }
5185 let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
5186 let shell_name = item
5187 .display_name
5188 .clone()
5189 .unwrap_or_else(|| item.template_id.clone());
5190
5191 if *slot != BodySlot::Waist
5193 && item.item_instance_id != moving_instance_id
5194 && Self::is_volume_container_stack(item)
5195 {
5196 Self::push_move_destination(
5197 &mut opts,
5198 format!("{shell_name} (worn {})", body_slot_label(*slot)),
5199 location.clone(),
5200 item.item_instance_id,
5201 from,
5202 from_parent_instance_id,
5203 );
5204 }
5205
5206 if *slot == BodySlot::Waist
5208 && Self::attaches_to_belt_loop(moving_template_id)
5209 && item.item_instance_id != moving_instance_id
5210 {
5211 Self::push_move_destination(
5212 &mut opts,
5213 format!("{shell_name} (belt loop)"),
5214 location.clone(),
5215 item.item_instance_id,
5216 from,
5217 from_parent_instance_id,
5218 );
5219 }
5220
5221 let context = if *slot == BodySlot::Waist {
5222 format!("on {shell_name}")
5223 } else {
5224 format!("in {shell_name}")
5225 };
5226 Self::append_nested_container_destinations(
5227 &mut opts,
5228 location,
5229 item,
5230 &context,
5231 from,
5232 from_parent_instance_id,
5233 moving_instance_id,
5234 );
5235 }
5236 for nc in self.nearby_containers() {
5237 if !nc.view.accessible {
5238 continue;
5239 }
5240 let location = flatland_protocol::InventoryLocation::Placed {
5241 container_id: nc.view.id.clone(),
5242 };
5243 Self::push_move_destination(
5244 &mut opts,
5245 format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
5246 location,
5247 nc.view.item_instance_id,
5248 from,
5249 from_parent_instance_id,
5250 );
5251 }
5252 let allow_drop = moving_instance_id
5253 .map(|id| !self.hand_equipped_instance_ids().contains(&id))
5254 .unwrap_or(true)
5255 && moving_instance_id
5256 .and_then(|id| self.stack_for_instance(id))
5257 .map(|stack| {
5258 !self.key_drop_blocked(&stack) && stack.template_id != PROPERTY_DEED_TEMPLATE
5259 })
5260 .unwrap_or(
5261 moving_template_id != KEY_TEMPLATE
5262 && moving_template_id != PROPERTY_DEED_TEMPLATE,
5263 );
5264 if allow_drop {
5265 opts.push(MoveOption {
5266 label: "Drop on the ground".into(),
5267 kind: MoveOptionKind::Drop,
5268 });
5269 }
5270 opts.push(MoveOption {
5271 label: "Cancel".into(),
5272 kind: MoveOptionKind::Cancel,
5273 });
5274 opts
5275 }
5276
5277 fn is_same_container_dest(
5278 dest_location: &flatland_protocol::InventoryLocation,
5279 dest_parent: Option<uuid::Uuid>,
5280 from: &flatland_protocol::InventoryLocation,
5281 from_parent: Option<uuid::Uuid>,
5282 ) -> bool {
5283 dest_location == from && dest_parent == from_parent
5284 }
5285
5286 fn push_move_destination(
5287 opts: &mut Vec<MoveOption>,
5288 label: String,
5289 location: flatland_protocol::InventoryLocation,
5290 parent_instance_id: Option<uuid::Uuid>,
5291 from: &flatland_protocol::InventoryLocation,
5292 from_parent_instance_id: Option<uuid::Uuid>,
5293 ) {
5294 if Self::is_same_container_dest(
5295 &location,
5296 parent_instance_id,
5297 from,
5298 from_parent_instance_id,
5299 ) {
5300 return;
5301 }
5302 opts.push(MoveOption {
5303 label,
5304 kind: MoveOptionKind::Move {
5305 location,
5306 parent_instance_id,
5307 },
5308 });
5309 }
5310
5311 fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
5312 stack.capacity_volume.is_some_and(|c| c > 0.0)
5313 }
5314
5315 fn attaches_to_belt_loop(template_id: &str) -> bool {
5316 matches!(template_id, "leather_pouch" | "dimensional_pouch")
5317 }
5318
5319 fn append_nested_container_destinations(
5320 opts: &mut Vec<MoveOption>,
5321 location: flatland_protocol::InventoryLocation,
5322 container: &flatland_protocol::ItemStack,
5323 context: &str,
5324 from: &flatland_protocol::InventoryLocation,
5325 from_parent_instance_id: Option<uuid::Uuid>,
5326 moving_instance_id: Option<uuid::Uuid>,
5327 ) {
5328 for child in &container.contents {
5329 if Self::is_volume_container_stack(child)
5330 && child.item_instance_id != moving_instance_id
5331 {
5332 let name = child
5333 .display_name
5334 .clone()
5335 .unwrap_or_else(|| child.template_id.clone());
5336 Self::push_move_destination(
5337 opts,
5338 format!("{name} ({context})"),
5339 location.clone(),
5340 child.item_instance_id,
5341 from,
5342 from_parent_instance_id,
5343 );
5344 }
5345 let nested_context = format!(
5346 "in {}",
5347 child.display_name.as_deref().unwrap_or(&child.template_id)
5348 );
5349 Self::append_nested_container_destinations(
5350 opts,
5351 location.clone(),
5352 child,
5353 &nested_context,
5354 from,
5355 from_parent_instance_id,
5356 moving_instance_id,
5357 );
5358 }
5359 }
5360
5361 fn clamp_inventory_indices(&mut self) {
5362 let n = self.inventory_selectable_rows().len();
5363 self.inventory_menu_index = if n == 0 {
5364 0
5365 } else {
5366 self.inventory_menu_index.min(n - 1)
5367 };
5368 if let Some(picker) = &self.move_picker {
5369 let pn = picker.options.len();
5370 self.move_picker_index = if pn == 0 {
5371 0
5372 } else {
5373 self.move_picker_index.min(pn - 1)
5374 };
5375 }
5376 }
5377
5378 fn sync_interior_map_context(&mut self) {
5383 if self.effective_inside_building().is_none() {
5384 self.interior_map = None;
5385 if let Some((platforms, transitions)) = self.z_bands_outdoor_backup.take() {
5386 self.z_platforms = platforms;
5387 self.z_transitions = transitions;
5388 }
5389 return;
5390 }
5391 self.sync_interior_z_bands();
5392 }
5393
5394 fn sync_interior_z_bands(&mut self) {
5396 if self.effective_inside_building().is_some() {
5397 if let Some(map) = &self.interior_map {
5398 if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
5399 if self.z_bands_outdoor_backup.is_none() {
5400 self.z_bands_outdoor_backup = Some((
5401 std::mem::take(&mut self.z_platforms),
5402 std::mem::take(&mut self.z_transitions),
5403 ));
5404 }
5405 self.z_platforms = map.z_platforms.clone();
5406 self.z_transitions = map.z_transitions.clone();
5407 }
5408 }
5409 }
5410 }
5411
5412 fn apply_snapshot_fields(
5413 &mut self,
5414 snapshot: &flatland_protocol::Snapshot,
5415 entity_id: EntityId,
5416 ) {
5417 self.tick = snapshot.tick;
5418 self.chunk_rev = snapshot.chunk_rev;
5419 self.content_rev = snapshot.content_rev;
5420 self.publish_rev = snapshot.publish_rev;
5421 self.resource_nodes = snapshot.resource_nodes.clone();
5422 self.replace_harvest_route_nodes(&snapshot.resource_nodes);
5423 self.ground_drops = snapshot.ground_drops.clone();
5424 self.placed_containers = snapshot.placed_containers.clone();
5425 self.world_x0 = snapshot.world_x0;
5426 self.world_y0 = snapshot.world_y0;
5427 self.world_width_m = snapshot.world_width_m;
5428 self.world_height_m = snapshot.world_height_m;
5429 self.world_clock = snapshot.world_clock;
5430 self.terrain_zones = snapshot.terrain_zones.clone();
5431 self.z_platforms = snapshot.z_platforms.clone();
5432 self.z_transitions = snapshot.z_transitions.clone();
5433 self.z_bands_outdoor_backup = None;
5435 self.buildings = snapshot.buildings.clone();
5436 self.doors = snapshot.doors.clone();
5437 self.interior_map = snapshot.interior_map.clone();
5438 self.npcs = snapshot.npcs.clone();
5439 self.blueprints = snapshot.blueprints.clone();
5440 self.building_materials = snapshot.building_materials.clone();
5441 self.sync_inventory_from_stacks(&snapshot.inventory);
5442 self.player = snapshot
5443 .entities
5444 .iter()
5445 .find(|e| e.id == entity_id)
5446 .cloned();
5447 self.entities = snapshot.entities.clone();
5448 self.quest_log = snapshot.quest_log.clone();
5449 self.apply_hired_workers(snapshot.hired_workers.clone());
5450 self.interactables = snapshot.interactables.clone();
5451 self.ledger = snapshot.ledger.clone();
5452 self.career = snapshot.career.clone();
5453 self.combat_fx = snapshot.combat_fx.clone();
5454 self.ground_hazards = snapshot.ground_hazards.clone();
5455 self.property_zones = snapshot.property_zones.clone();
5456 self.tax_zones = snapshot.tax_zones.clone();
5457 self.growth_zones = snapshot.growth_zones.clone();
5458 self.biome_zones = snapshot.biome_zones.clone();
5459 self.terrain_kind_nav = snapshot.terrain_kind_nav.clone();
5460 self.property_plots = snapshot.property_plots.clone();
5461 self.property_plot_settings = snapshot.property_plot_settings.clone();
5462 self.sync_item_catalog(&snapshot.item_catalog);
5463 if self.effective_inside_building().is_some() {
5466 self.z_bands_outdoor_backup = Some((Vec::new(), Vec::new()));
5467 }
5468 self.sync_interior_map_context();
5469 self.refresh_whisper_range();
5470 self.sync_gameplay_audio();
5471 }
5472
5473 fn replace_harvest_route_nodes(
5474 &mut self,
5475 incoming: &[flatland_protocol::ResourceNodeView],
5476 ) {
5477 self.harvest_route_nodes = incoming
5478 .iter()
5479 .filter(|n| crate::is_harvest_route_node(n))
5480 .cloned()
5481 .collect();
5482 }
5483
5484 fn upsert_harvest_route_nodes(
5485 &mut self,
5486 incoming: &[flatland_protocol::ResourceNodeView],
5487 ) {
5488 for node in incoming.iter().filter(|n| crate::is_harvest_route_node(n)) {
5489 if let Some(existing) = self
5490 .harvest_route_nodes
5491 .iter_mut()
5492 .find(|n| n.id == node.id)
5493 {
5494 *existing = node.clone();
5495 } else {
5496 self.harvest_route_nodes.push(node.clone());
5497 }
5498 }
5499 }
5500
5501 fn refresh_inventory_ui(&mut self) {
5505 if let Some(picker) = &self.move_picker {
5506 let instance_id = picker.item_instance_id;
5507 let still_exists = self
5508 .inventory_selectable_rows()
5509 .iter()
5510 .any(|r| r.stack.item_instance_id == Some(instance_id));
5511 if !still_exists {
5512 self.move_picker = None;
5513 self.show_move_picker = false;
5514 }
5515 }
5516 if let Some(picker) = &self.destroy_picker {
5517 let instance_id = picker.item_instance_id;
5518 let still_exists = self
5519 .inventory_selectable_rows()
5520 .iter()
5521 .any(|r| r.stack.item_instance_id == Some(instance_id));
5522 if !still_exists {
5523 self.destroy_picker = None;
5524 self.show_destroy_picker = false;
5525 self.destroy_confirm_pending = false;
5526 }
5527 }
5528 self.clamp_inventory_indices();
5529 }
5530
5531 fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
5537 let selected_id = self
5538 .hired_workers
5539 .get(self.workers_menu_index)
5540 .map(|w| w.instance_id.clone());
5541 let previous_worker_ids: HashSet<String> = self
5542 .hired_workers
5543 .iter()
5544 .map(|worker| worker.instance_id.clone())
5545 .collect();
5546 workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
5547 let now = Instant::now();
5548 let saw_new_worker = workers
5549 .iter()
5550 .any(|worker| !previous_worker_ids.contains(&worker.instance_id));
5551 for worker in &workers {
5552 let was_hit = self
5553 .hired_workers
5554 .iter()
5555 .find(|previous| previous.instance_id == worker.instance_id)
5556 .is_some_and(|previous| {
5557 matches!(worker.mode, flatland_protocol::WorkerModeView::Defender)
5558 && worker.vitals.health_pct + 0.01 < previous.vitals.health_pct
5559 });
5560 if was_hit {
5561 self.worker_health_ring_until
5562 .insert(worker.entity_id, now + WORKER_HEALTH_RING_HOLD);
5563 }
5564 }
5565 let worker_entity_ids: HashSet<EntityId> =
5566 workers.iter().map(|worker| worker.entity_id).collect();
5567 self.worker_health_ring_until
5568 .retain(|entity_id, _| worker_entity_ids.contains(entity_id));
5569 for w in &workers {
5570 let prev_err = self
5571 .hired_workers
5572 .iter()
5573 .find(|p| p.instance_id == w.instance_id)
5574 .and_then(|p| p.last_error.as_deref());
5575 let new_err = w.last_error.as_deref();
5576 if new_err != prev_err {
5577 if let Some(err) = new_err {
5578 if !worker_error_is_transient(err) {
5579 self.push_log(format!("Worker {}: {err}", w.label));
5580 }
5581 }
5582 }
5583 }
5584 let mut next_display = BTreeMap::new();
5585 let mut next_errors = BTreeMap::new();
5586 for w in &workers {
5587 let mut sticky = self
5588 .worker_step_display
5589 .remove(&w.instance_id)
5590 .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
5591 sticky.observe(&w.step_label, now);
5592 next_display.insert(w.instance_id.clone(), sticky);
5593
5594 let mut err_sticky = self
5595 .worker_error_display
5596 .remove(&w.instance_id)
5597 .unwrap_or_default();
5598 err_sticky.observe(w.last_error.as_deref(), now);
5599 if err_sticky.shown(now).is_some() {
5600 next_errors.insert(w.instance_id.clone(), err_sticky);
5601 }
5602 }
5603 self.worker_step_display = next_display;
5604 self.worker_error_display = next_errors;
5605 self.hired_workers = workers;
5606 if saw_new_worker {
5607 self.pending_worker_hire_since = None;
5608 }
5609 self.sync_worker_take_picker_from_hired();
5610 if let Some(id) = selected_id {
5611 if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
5612 self.workers_menu_index = idx;
5613 return;
5614 }
5615 }
5616 if self.workers_menu_index >= self.hired_workers.len() {
5617 self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
5618 }
5619 }
5620
5621 fn sync_worker_take_picker_from_hired(&mut self) {
5623 if !self.show_worker_take_picker {
5624 return;
5625 }
5626 let Some(picker) = self.worker_take_picker.clone() else {
5627 return;
5628 };
5629 let Some(worker) = self
5630 .hired_workers
5631 .iter()
5632 .find(|w| w.instance_id == picker.worker_instance_id)
5633 .cloned()
5634 else {
5635 self.show_worker_take_picker = false;
5636 self.worker_take_picker = None;
5637 self.worker_take_picker_index = 0;
5638 return;
5639 };
5640 let options: Vec<WorkerGiveOption> = worker
5641 .inventory
5642 .iter()
5643 .filter_map(|stack| {
5644 let item_instance_id = stack.item_instance_id?;
5645 let label = stack
5646 .display_name
5647 .clone()
5648 .unwrap_or_else(|| stack.template_id.clone());
5649 let label = if stack.quantity > 1 {
5650 format!("{label} ×{}", stack.quantity)
5651 } else {
5652 label
5653 };
5654 Some(WorkerGiveOption {
5655 item_instance_id,
5656 label,
5657 quantity: stack.quantity,
5658 template_id: stack.template_id.clone(),
5659 })
5660 })
5661 .collect();
5662 if options.is_empty() {
5663 self.show_worker_take_picker = false;
5664 self.worker_take_picker = None;
5665 self.worker_take_picker_index = 0;
5666 return;
5667 }
5668 let prev_id = picker
5669 .options
5670 .get(self.worker_take_picker_index)
5671 .map(|o| o.item_instance_id);
5672 let idx = prev_id
5673 .and_then(|id| options.iter().position(|o| o.item_instance_id == id))
5674 .unwrap_or(0)
5675 .min(options.len().saturating_sub(1));
5676 let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
5677 let quantity = picker.quantity.clamp(1, max_qty);
5678 self.worker_take_picker_index = idx;
5679 self.worker_take_picker = Some(WorkerTakePicker {
5680 worker_instance_id: picker.worker_instance_id,
5681 worker_label: picker.worker_label,
5682 options,
5683 quantity,
5684 });
5685 }
5686
5687 pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
5689 self.worker_step_display
5690 .get(worker_instance_id)
5691 .map(|s| s.shown.as_str())
5692 .or_else(|| {
5693 self.hired_workers
5694 .iter()
5695 .find(|w| w.instance_id == worker_instance_id)
5696 .map(|w| w.step_label.as_str())
5697 })
5698 .unwrap_or("")
5699 }
5700
5701 pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
5703 let now = Instant::now();
5704 self.worker_error_display
5705 .get(worker_instance_id)
5706 .and_then(|s| s.shown(now))
5707 .or_else(|| {
5708 self.hired_workers
5709 .iter()
5710 .find(|w| w.instance_id == worker_instance_id)
5711 .and_then(|w| w.last_error.as_deref())
5712 .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
5713 })
5714 .filter(|e| !worker_error_is_hud_noise(e))
5715 }
5716
5717 fn apply_combat_hud(&mut self, combat: &CombatHud) {
5718 self.in_combat = combat.in_combat;
5719 self.auto_attack = combat.auto_attack;
5720 self.combat_has_los = combat.has_los;
5721 self.attack_cd_ticks = combat.attack_cd_ticks;
5722 self.gcd_ticks = combat.gcd_ticks;
5723 self.weapon_ability_id = combat.ability_id.clone();
5724 self.mainhand_template_id = combat.mainhand_template_id.clone();
5725 self.mainhand_label = combat.mainhand_label.clone();
5726 self.mainhand_instance_id = combat.mainhand_instance_id;
5727 self.offhand_template_id = combat.offhand_template_id.clone();
5728 self.offhand_label = combat.offhand_label.clone();
5729 self.offhand_instance_id = combat.offhand_instance_id;
5730 self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
5731 1
5732 } else {
5733 combat.mainhand_hand_slots
5734 };
5735 self.defense = combat.defense.clone();
5736 self.worn = combat.worn.iter().cloned().collect();
5737 self.carry_mass = combat.carry_mass;
5738 self.carry_mass_max = combat.carry_mass_max;
5739 self.encumbrance = combat.encumbrance;
5740 self.move_speed_mps = combat.move_speed_mps;
5741 self.move_speed_mult = combat.move_speed_mult;
5742 self.cast_progress = combat.cast.clone();
5743 self.timed_channel = combat.timed_channel.clone();
5744 if self.active_craft_channel().is_none() {
5745 self.craft_channel_blueprint_id = None;
5746 }
5747 self.plot_build_offer = combat.plot_build.clone();
5748 self.ability_cooldowns = combat.ability_cooldowns.clone();
5749 self.blocking_active = combat.blocking_active;
5750 self.max_target_slots = combat.max_target_slots.max(1);
5751 self.combat_slots = combat.slots.clone();
5752 self.rotation_presets = combat.rotation_presets.clone();
5753 self.known_abilities = combat.known_abilities.clone();
5754 self.ability_meta = combat
5755 .ability_meta
5756 .iter()
5757 .cloned()
5758 .map(|meta| (meta.id.clone(), meta))
5759 .collect();
5760 self.ability_mastery = combat
5761 .ability_mastery
5762 .iter()
5763 .cloned()
5764 .map(|row| (row.ability_id.clone(), row))
5765 .collect();
5766 self.hotbar = combat.hotbar.clone();
5767 self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
5768 self.keychain_stacks = combat.keychain.clone();
5769 self.whisper_pouch_stacks = combat.whisper_pouch.clone();
5770 self.combat_target_detail = combat.target.clone();
5771 self.statuses = combat.statuses.clone();
5772 self.combat_target = combat.target_entity_id;
5773 if combat.progression_xp_base > 0.0 {
5774 self.progression_curve = Some(flatland_protocol::ProgressionCurve {
5775 baseline_display: combat.progression_baseline,
5776 xp_base: combat.progression_xp_base,
5777 xp_growth: combat.progression_xp_growth,
5778 });
5779 }
5780 if let Some(xp) = &combat.progression_xp {
5781 if let Some(player) = &mut self.player {
5782 player.progression_xp = Some(xp.clone());
5783 if let Some(attrs) = combat.attributes {
5784 player.attributes = Some(attrs);
5785 }
5786 if let Some(skills) = &combat.skills {
5787 player.skills = Some(skills.clone());
5788 }
5789 }
5790 }
5791 if let Some(label) = &combat.target_label {
5792 self.combat_target_label = Some(label.clone());
5793 } else if let Some(id) = combat.target_entity_id {
5794 self.combat_target_label = self
5795 .entities
5796 .iter()
5797 .find(|e| e.id == id)
5798 .map(|e| e.label.clone())
5799 .or_else(|| self.combat_target_label.clone());
5800 }
5801 self.refresh_inventory_ui();
5802 }
5803
5804 pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
5806 self.combat_slots
5807 .iter()
5808 .find(|s| s.slot_index == slot)
5809 .and_then(|s| s.target_entity_id)
5810 .or_else(|| if slot == 1 { self.combat_target } else { None })
5811 }
5812
5813 pub fn ability_allows_ground(&self, ability_id: &str) -> bool {
5815 self.ability_meta
5816 .get(ability_id)
5817 .map(|meta| matches!(meta.aim_mode.as_str(), "ground" | "either"))
5818 .unwrap_or(self.ground_target.is_some())
5821 }
5822
5823 pub fn ability_requires_ground(&self, ability_id: &str) -> bool {
5825 self.ability_meta
5826 .get(ability_id)
5827 .map(|meta| meta.aim_mode == "ground")
5828 .unwrap_or(false)
5829 }
5830
5831 pub fn ability_auto_rotation_eligible(&self, ability_id: &str) -> bool {
5834 self.ability_meta
5835 .get(ability_id)
5836 .map(|meta| meta.auto_rotation_eligible)
5837 .unwrap_or(true)
5838 }
5839
5840 pub fn set_ground_target(&mut self, x: f32, y: f32) {
5842 self.ground_target = Some((x, y, 0.0));
5843 }
5844
5845 pub fn clear_ground_target(&mut self) {
5847 self.ground_target = None;
5848 }
5849
5850 pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
5853 if !(1..=9).contains(&slot_1_to_9) {
5854 return None;
5855 }
5856 self.hotbar
5857 .get((slot_1_to_9 - 1) as usize)
5858 .and_then(|a| a.as_deref())
5859 .filter(|id| !id.is_empty())
5860 }
5861
5862 pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
5864 let binding = self.hotbar_ability(slot_1_to_9)?;
5865 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
5866 let name = self
5867 .inventory_hints
5868 .get(template_id)
5869 .map(|h| h.display_name.as_str())
5870 .unwrap_or(template_id);
5871 let qty = self.inventory.get(template_id).copied().unwrap_or(0);
5872 Some(format!("{name}×{qty}"))
5873 } else {
5874 Some(binding.to_string())
5875 }
5876 }
5877
5878 pub fn loadout_ability_choices(&self) -> Vec<String> {
5880 let mut out = self.known_abilities.clone();
5881 let weapon = self.weapon_ability_id.trim();
5882 if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
5883 out.push(weapon.to_string());
5884 }
5885 out
5886 }
5887
5888 pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
5890 let mut out = Vec::new();
5891 for ability in self.loadout_ability_choices() {
5892 let meta = if ability == self.weapon_ability_id {
5893 Some("weapon".into())
5894 } else {
5895 None
5896 };
5897 out.push(LoadoutHotbarChoice {
5898 binding: ability.clone(),
5899 label: ability,
5900 meta,
5901 });
5902 }
5903 let mut consumables: Vec<(String, String, u32)> = Vec::new();
5904 for stack in &self.inventory_stacks {
5905 if Self::stack_is_item_grant(stack) {
5906 continue;
5907 }
5908 if Self::stack_is_blueprint_scroll(stack) {
5909 continue;
5910 }
5911 if self.inventory_item_category(&stack.template_id) != Some("consumable")
5912 && !Self::stack_is_serving(stack)
5913 {
5914 continue;
5915 }
5916 let qty = stack.quantity.max(1);
5917 if let Some((_, _, existing)) = consumables
5918 .iter_mut()
5919 .find(|(id, _, _)| id == &stack.template_id)
5920 {
5921 *existing = existing.saturating_add(qty);
5922 } else {
5923 let label = stack
5924 .display_name
5925 .clone()
5926 .or_else(|| {
5927 self.inventory_hints
5928 .get(&stack.template_id)
5929 .map(|h| h.display_name.clone())
5930 })
5931 .unwrap_or_else(|| stack.template_id.clone());
5932 consumables.push((stack.template_id.clone(), label, qty));
5933 }
5934 }
5935 consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
5936 for (template_id, label, qty) in consumables {
5937 out.push(LoadoutHotbarChoice {
5938 binding: flatland_protocol::hotbar_consumable_binding(&template_id),
5939 label: format!("{label} ×{qty}"),
5940 meta: Some("use".into()),
5941 });
5942 }
5943 out
5944 }
5945
5946 pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
5948 self.combat_candidates()
5949 }
5950
5951 pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
5953 let (px, py) = self.player_position();
5954 let dist = |id: EntityId| {
5955 self.entities
5956 .iter()
5957 .find(|e| e.id == id)
5958 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
5959 .unwrap_or(f32::MAX)
5960 };
5961
5962 let mut allies = Vec::new();
5963 if let Some(me) = self.player.as_ref() {
5965 let alive = me
5966 .vitals
5967 .as_ref()
5968 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
5969 .unwrap_or(true);
5970 if alive {
5971 allies.push((self.entity_id, "Yourself".into()));
5972 }
5973 }
5974 for entity in &self.entities {
5975 if entity.id == self.entity_id {
5976 continue;
5977 }
5978 if entity.vitals.is_some() {
5979 let alive = entity
5980 .vitals
5981 .as_ref()
5982 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
5983 .unwrap_or(true);
5984 if alive {
5985 allies.push((entity.id, entity.label.clone()));
5986 }
5987 }
5988 }
5989 allies.sort_by(|(a, _), (b, _)| {
5990 if *a == self.entity_id {
5991 return std::cmp::Ordering::Less;
5992 }
5993 if *b == self.entity_id {
5994 return std::cmp::Ordering::Greater;
5995 }
5996 dist(*a)
5997 .partial_cmp(&dist(*b))
5998 .unwrap_or(std::cmp::Ordering::Equal)
5999 });
6000
6001 let mut monsters = self.combat_candidates();
6002 monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
6003 allies.into_iter().chain(monsters).collect()
6004 }
6005
6006 fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
6007 match slot_index {
6008 2 => self.t2_candidates(),
6009 _ => self.t1_candidates(),
6010 }
6011 }
6012
6013 pub fn pick_combat_target_at(
6015 &self,
6016 wx: f32,
6017 wy: f32,
6018 slot_index: u8,
6019 radius_m: f32,
6020 ) -> Option<(EntityId, String)> {
6021 let mut best: Option<(f32, EntityId, String)> = None;
6022 for (id, label) in self.candidates_for_slot(slot_index) {
6023 let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
6024 if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
6026 let d = distance(wx, wy, npc.x, npc.y);
6027 if d <= radius_m {
6028 best = match best {
6029 Some((bd, _, _)) if bd <= d => best,
6030 _ => Some((d, id, label)),
6031 };
6032 }
6033 }
6034 continue;
6035 };
6036 let d = distance(
6037 wx,
6038 wy,
6039 entity.transform.position.x,
6040 entity.transform.position.y,
6041 );
6042 if d <= radius_m {
6043 best = match best {
6044 Some((bd, _, _)) if bd <= d => best,
6045 _ => Some((d, id, label)),
6046 };
6047 }
6048 }
6049 best.map(|(_, id, label)| (id, label))
6050 }
6051
6052 pub(crate) fn restore_from_welcome(
6054 &mut self,
6055 session_id: SessionId,
6056 entity_id: EntityId,
6057 snapshot: &flatland_protocol::Snapshot,
6058 ) {
6059 self.clear_harvest_state();
6060 self.disconnect_reason = None;
6061 self.show_stats = false;
6062 self.show_craft_menu = false;
6063 self.show_shop_menu = false;
6064 self.shop_catalog = None;
6065 self.show_inventory_menu = false;
6066 self.session_id = session_id;
6067 self.entity_id = entity_id;
6068 self.connected = true;
6069 self.apply_snapshot_fields(snapshot, entity_id);
6070 if let Some(combat) = &snapshot.combat {
6071 self.apply_combat_hud(combat);
6072 let stacks = self.inventory_stacks.clone();
6073 self.sync_inventory_from_stacks(&stacks);
6074 }
6075 }
6076
6077 fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
6078 self.tick = delta.tick;
6079 self.world_clock = delta.world_clock;
6080
6081 if delta.entities.is_empty() {
6083 self.ground_drops = delta.ground_drops.clone();
6084 self.combat_fx = delta.combat_fx.clone();
6085 self.ground_hazards = delta.ground_hazards.clone();
6086 self.property_plots = delta.property_plots.clone();
6087 self.apply_terrain_overlays(&delta.terrain_overlays);
6088 if let Some(combat) = &delta.combat {
6089 self.apply_combat_hud(combat);
6090 let stacks = self.inventory_stacks.clone();
6091 self.sync_inventory_from_stacks(&stacks);
6092 }
6093 self.refresh_whisper_range();
6095 self.sync_gameplay_audio();
6096 return;
6097 }
6098 if !delta.buildings.is_empty() {
6099 self.buildings = delta.buildings.clone();
6100 }
6101 if !delta.blueprints.is_empty() {
6102 self.blueprints = delta.blueprints.clone();
6103 }
6104 if !delta.building_materials.is_empty() {
6105 self.building_materials = delta.building_materials.clone();
6106 }
6107 self.sync_inventory_from_stacks(&delta.inventory);
6108
6109 if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
6110 self.player = Some(updated.clone());
6111 }
6112 self.entities = delta.entities.clone();
6113 if self.player.is_none() {
6114 self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
6115 }
6116
6117 self.sync_interior_map_context();
6118
6119 if !delta.resource_nodes.is_empty() {
6123 self.resource_nodes = delta.resource_nodes.clone();
6124 self.upsert_harvest_route_nodes(&delta.resource_nodes);
6125 } else if delta.interior_map.is_some() || self.effective_inside_building().is_some() {
6126 self.resource_nodes = delta.resource_nodes.clone();
6127 }
6128 self.ground_drops = delta.ground_drops.clone();
6129 self.placed_containers = delta.placed_containers.clone();
6131 if !delta.doors.is_empty() {
6132 self.doors = delta.doors.clone();
6133 }
6134 if self.effective_inside_building().is_some() {
6135 if let Some(map) = &delta.interior_map {
6136 self.interior_map = Some(map.clone());
6137 }
6138 } else {
6139 self.interior_map = None;
6140 }
6141 self.sync_interior_z_bands();
6142 self.npcs = delta.npcs.clone();
6144 if !delta.quest_log.is_empty() {
6145 self.quest_log = delta.quest_log.clone();
6146 }
6147 self.apply_hired_workers(delta.hired_workers.clone());
6148 if !delta.interactables.is_empty() {
6149 self.interactables = delta.interactables.clone();
6150 }
6151 if delta.ledger.is_some() {
6152 self.ledger = delta.ledger.clone();
6153 }
6154 if delta.career.is_some() {
6155 self.career = delta.career.clone();
6156 }
6157 self.combat_fx = delta.combat_fx.clone();
6158 self.ground_hazards = delta.ground_hazards.clone();
6159 if !delta.property_plots.is_empty() {
6161 self.property_plots = delta.property_plots.clone();
6162 }
6163 self.apply_terrain_overlays(&delta.terrain_overlays);
6164 if let Some(combat) = &delta.combat {
6165 self.apply_combat_hud(combat);
6166 let stacks = self.inventory_stacks.clone();
6167 self.sync_inventory_from_stacks(&stacks);
6168 } else {
6169 self.refresh_inventory_ui();
6170 }
6171 self.refresh_whisper_range();
6172 self.sync_gameplay_audio();
6173 }
6174
6175 fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
6178 self.terrain_zones.retain(|z| !z.id.starts_with("rt:"));
6179 self.terrain_zones.extend(overlays.iter().cloned());
6180 }
6181
6182 fn refresh_whisper_range(&mut self) {
6185 let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
6186 return;
6187 };
6188 let (px, py) = self.player_position();
6189 let in_range = self.entities.iter().any(|e| {
6190 e.id == peer
6191 && distance(px, py, e.transform.position.x, e.transform.position.y)
6192 <= INTERACTION_RADIUS_M
6193 });
6194 if !in_range {
6195 self.social_chat.cancel_whisper_out_of_range();
6196 }
6197 }
6198
6199 pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
6201 let (px, py) = self.player_position();
6202 let mut out = Vec::new();
6203 for npc in &self.npcs {
6204 let Some(eid) = npc.entity_id else {
6205 continue;
6206 };
6207 let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
6208 let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
6209 if alive && has_hp {
6210 out.push((eid, npc.label.clone()));
6211 }
6212 }
6213 out.sort_by(|(a_id, a_label), (b_id, b_label)| {
6214 let dist = |id: EntityId| {
6215 self.entities
6216 .iter()
6217 .find(|e| e.id == id)
6218 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
6219 .unwrap_or(f32::MAX)
6220 };
6221 dist(*a_id)
6222 .partial_cmp(&dist(*b_id))
6223 .unwrap_or(std::cmp::Ordering::Equal)
6224 .then_with(|| a_label.cmp(b_label))
6225 .then_with(|| a_id.cmp(b_id))
6226 });
6227 out
6228 }
6229
6230 pub fn refresh_combat_target_label(&mut self) {
6231 let Some(id) = self.combat_target else {
6232 return;
6233 };
6234 if let Some((_, label)) = self
6235 .combat_candidates()
6236 .into_iter()
6237 .find(|(eid, _)| *eid == id)
6238 {
6239 self.combat_target_label = Some(label);
6240 } else if let Some(label) = self
6241 .entities
6242 .iter()
6243 .find(|e| e.id == id)
6244 .map(|e| e.label.clone())
6245 {
6246 self.combat_target_label = Some(label);
6247 }
6248 }
6249
6250 pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
6251 self.quest_log
6252 .iter()
6253 .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
6254 .collect()
6255 }
6256
6257 pub fn has_worker_lodging(&self) -> bool {
6259 self.free_worker_lodging_slots() > 0
6260 }
6261
6262 pub fn free_worker_lodging_slots(&self) -> i64 {
6264 let slots: u32 = self
6265 .placed_containers
6266 .iter()
6267 .filter(|c| match (self.character_id, c.owner_character_id) {
6268 (Some(me), Some(owner)) => me == owner,
6269 (Some(_), None) => false,
6270 (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
6271 })
6272 .map(|c| c.worker_lodging_capacity.unwrap_or(0))
6273 .sum();
6274 let used = self.hired_workers.len() as u32;
6275 slots as i64 - used as i64
6276 }
6277
6278 pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
6280 let mut names: Vec<String> = self
6281 .hired_workers
6282 .iter()
6283 .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
6284 .map(|w| w.label.clone())
6285 .collect();
6286 names.sort();
6287 names
6288 }
6289
6290 pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
6292 let is_lodging = self
6293 .placed_containers
6294 .iter()
6295 .find(|c| c.id == container_id)
6296 .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
6297 if !is_lodging {
6298 return None;
6299 }
6300 let names = self.lodging_occupant_labels(container_id);
6301 Some(if names.is_empty() {
6302 "vacant".into()
6303 } else {
6304 names.join(", ")
6305 })
6306 }
6307
6308 pub fn lodging_is_occupied(&self, container_id: &str) -> bool {
6310 matches!(
6311 self.lodging_occupancy_label(container_id),
6312 Some(label) if label != "vacant"
6313 )
6314 }
6315
6316 pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
6317 self.quest_log
6318 .iter()
6319 .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
6320 .or_else(|| {
6321 self.quest_log
6322 .iter()
6323 .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
6324 })
6325 }
6326
6327 pub fn nearby_lockable_door(&self) -> bool {
6329 let (px, py) = self.player_position();
6330 self.doors
6331 .iter()
6332 .any(|d| d.lock_id.is_some() && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M)
6333 }
6334
6335 pub fn nearby_open_player_door(&self) -> bool {
6337 if self.effective_inside_building().is_some() {
6338 return false;
6339 }
6340 let (px, py) = self.player_position();
6341 self.doors.iter().any(|d| {
6342 if !d.open || d.locked {
6343 return false;
6344 }
6345 let player_house = self
6346 .buildings
6347 .iter()
6348 .find(|b| b.id == d.building_id)
6349 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6350 player_house && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M
6351 })
6352 }
6353
6354 pub fn nearby_player_exit_door(&self) -> bool {
6356 let Some(bid) = self.effective_inside_building() else {
6357 return false;
6358 };
6359 let (px, py) = self.player_position();
6360 self.doors.iter().any(|d| {
6361 if d.building_id != bid || d.portal.is_none() {
6362 return false;
6363 }
6364 let player_house = self
6365 .buildings
6366 .iter()
6367 .find(|b| b.id == d.building_id)
6368 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6369 player_house && (d.x - px).hypot(d.y - py) <= 1.5
6370 })
6371 }
6372
6373 pub fn nearest_interact_target(&self) -> Option<String> {
6375 let (px, py) = self.player_position();
6376 let inside = self.effective_inside_building();
6377
6378 #[derive(Clone, Copy, PartialEq, Eq)]
6379 enum Kind {
6380 Player,
6381 Npc,
6382 HiredWorker,
6383 QuestBoard,
6384 ExitDoor,
6385 EnterDoor,
6386 }
6387
6388 fn kind_class(kind: Kind) -> u8 {
6389 match kind {
6390 Kind::EnterDoor => 0,
6391 Kind::QuestBoard => 1,
6392 Kind::Player | Kind::Npc => 2,
6393 Kind::ExitDoor => 3,
6394 Kind::HiredWorker => 4,
6395 }
6396 }
6397
6398 fn kind_priority(kind: Kind) -> u8 {
6399 match kind {
6400 Kind::EnterDoor => 0,
6401 Kind::QuestBoard => 1,
6402 Kind::Player | Kind::Npc => 2,
6403 Kind::ExitDoor => 3,
6404 Kind::HiredWorker => 4,
6405 }
6406 }
6407
6408 let mut best: Option<(f32, Kind, String)> = None;
6409
6410 let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
6411 if dist > max {
6412 return;
6413 }
6414 let replace = match best {
6415 None => true,
6416 Some((_, bk, _)) if kind_class(kind) < kind_class(bk) => true,
6417 Some((bd, bk, _))
6418 if kind_class(kind) == kind_class(bk) && dist < bd - 0.05 =>
6419 {
6420 true
6421 }
6422 Some((bd, bk, _))
6423 if kind_class(kind) == kind_class(bk) && (dist - bd).abs() <= 0.05 =>
6424 {
6425 kind_priority(kind) < kind_priority(bk)
6426 }
6427 _ => false,
6428 };
6429 if replace {
6430 best = Some((dist, kind, id));
6431 }
6432 };
6433
6434 for npc in &self.npcs {
6435 consider(
6436 distance(px, py, npc.x, npc.y),
6437 INTERACTION_RADIUS_M,
6438 Kind::Npc,
6439 npc.id.clone(),
6440 );
6441 }
6442
6443 for worker in &self.hired_workers {
6444 consider(
6445 distance(px, py, worker.x, worker.y),
6446 INTERACTION_RADIUS_M,
6447 Kind::HiredWorker,
6448 worker.instance_id.clone(),
6449 );
6450 }
6451
6452 for entity in &self.entities {
6453 if entity.id == self.entity_id
6454 || entity.vitals.is_none()
6455 || entity.label.trim().is_empty()
6456 {
6457 continue;
6458 }
6459 if self.hired_workers.iter().any(|w| w.entity_id == entity.id) {
6461 continue;
6462 }
6463 consider(
6464 distance(
6465 px,
6466 py,
6467 entity.transform.position.x,
6468 entity.transform.position.y,
6469 ),
6470 INTERACTION_RADIUS_M,
6471 Kind::Player,
6472 entity.id.to_string(),
6473 );
6474 }
6475
6476 for door in &self.doors {
6477 if let Some(ref bid) = inside {
6478 if door.building_id != *bid {
6479 continue;
6480 }
6481 let is_exit = door.portal.is_some();
6482 let max = if is_exit {
6483 INTERACTION_RADIUS_M
6484 } else {
6485 DOOR_INTERACTION_RADIUS_M
6486 };
6487 let kind = if is_exit {
6488 Kind::ExitDoor
6489 } else {
6490 Kind::EnterDoor
6491 };
6492 consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
6493 continue;
6494 }
6495 consider(
6496 distance(px, py, door.x, door.y),
6497 DOOR_INTERACTION_RADIUS_M,
6498 Kind::EnterDoor,
6499 door.id.clone(),
6500 );
6501 }
6502
6503 if inside.is_none() {
6504 for inter in &self.interactables {
6505 if inter.kind == "quest_board" {
6506 consider(
6507 distance(px, py, inter.x, inter.y),
6508 QUEST_BOARD_INTERACTION_RADIUS_M,
6509 Kind::QuestBoard,
6510 inter.id.clone(),
6511 );
6512 }
6513 }
6514 }
6515
6516 best.map(|(_, _, id)| id)
6517 }
6518
6519 pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
6521 if self.effective_inside_building().is_some() {
6522 return None;
6523 }
6524 let (px, py) = self.player_position();
6525 self.interactables
6526 .iter()
6527 .filter(|i| i.kind == "quest_board")
6528 .map(|i| {
6529 let label = if i.label.is_empty() {
6530 "Quest board".to_string()
6531 } else {
6532 i.label.clone()
6533 };
6534 (label, distance(px, py, i.x, i.y))
6535 })
6536 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
6537 }
6538
6539 pub fn template_display_name(&self, template_id: &str) -> String {
6541 if let Some(name) = self
6542 .inventory_hints
6543 .get(template_id)
6544 .map(|h| h.display_name.clone())
6545 .filter(|n| !n.is_empty())
6546 {
6547 return name;
6548 }
6549 if let Some(entry) = self.item_catalog.get(template_id) {
6550 if !entry.display_name.trim().is_empty() {
6551 return entry.display_name.clone();
6552 }
6553 }
6554 humanize_template_id(template_id)
6555 }
6556
6557 pub fn catalog_entry(&self, template_id: &str) -> Option<&ItemCatalogEntryView> {
6558 self.item_catalog.get(template_id)
6559 }
6560
6561 pub fn blueprint_item_label(&self, template_id: &str, display_name: &str) -> String {
6563 if !display_name.is_empty() {
6564 display_name.to_string()
6565 } else {
6566 self.template_display_name(template_id)
6567 }
6568 }
6569
6570 pub fn blueprint_output_label(&self, blueprint: &BlueprintView) -> String {
6571 self.blueprint_item_label(&blueprint.output, &blueprint.output_display_name)
6572 }
6573
6574 pub fn blueprint_ingredient_label(
6575 &self,
6576 input: &flatland_protocol::BlueprintIngredientView,
6577 ) -> String {
6578 self.blueprint_item_label(&input.template_id, &input.display_name)
6579 }
6580
6581 pub fn blueprint_tool_label(&self, tool: &flatland_protocol::ToolRequirementView) -> String {
6582 self.blueprint_item_label(&tool.item, &tool.display_name)
6583 }
6584
6585 pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
6587 use crate::worker_route_editor::{
6588 node_candidates, node_candidates_stable, route_editor_lodging_anchor,
6589 };
6590 let nodes = if self.harvest_route_nodes.is_empty() {
6591 &self.resource_nodes
6592 } else {
6593 &self.harvest_route_nodes
6594 };
6595 let lodging = self
6596 .worker_route_editor
6597 .as_ref()
6598 .and_then(|ed| ed.lodging_container_id.as_deref());
6599 match route_editor_lodging_anchor(lodging, &self.placed_containers) {
6600 Some((ax, ay)) => node_candidates(nodes, ax, ay),
6601 None => node_candidates_stable(nodes),
6602 }
6603 }
6604
6605 pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
6606 if dist_m.is_nan() {
6607 return "—".into();
6608 }
6609 let from_bed = self
6610 .worker_route_editor
6611 .as_ref()
6612 .and_then(|ed| ed.lodging_container_id.as_deref())
6613 .and_then(|id| {
6614 self.placed_containers
6615 .iter()
6616 .find(|c| c.id == id)
6617 .map(|c| c.display_name.clone())
6618 });
6619 match from_bed {
6620 Some(bed) => format!("{dist_m:.0}m from {bed}"),
6621 None => format!("{dist_m:.0}m"),
6622 }
6623 }
6624
6625 pub fn placed_container_public_label(
6627 &self,
6628 c: &flatland_protocol::PlacedContainerView,
6629 ) -> String {
6630 let is_owner = match (self.character_id, c.owner_character_id) {
6631 (Some(me), Some(owner)) => me == owner,
6632 _ => false,
6633 };
6634 if is_owner {
6635 c.display_name.clone()
6636 } else {
6637 self.template_display_name(&c.template_id)
6638 }
6639 }
6640
6641 pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
6643 let mut out = Vec::new();
6644 for stack in &self.inventory_stacks {
6645 if stack.template_id == KEY_TEMPLATE {
6646 out.push(KeychainEntry {
6647 stack: stack.clone(),
6648 stowed: false,
6649 });
6650 }
6651 }
6652 for stack in &self.keychain_stacks {
6653 if stack.template_id == KEY_TEMPLATE {
6654 out.push(KeychainEntry {
6655 stack: stack.clone(),
6656 stowed: true,
6657 });
6658 }
6659 }
6660 out
6661 }
6662
6663 pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
6665 if stack.template_id != KEY_TEMPLATE {
6666 return None;
6667 }
6668 if let Some(name) = stack
6669 .props
6670 .get(PROP_OPENS_CONTAINER_NAME)
6671 .filter(|n| !n.is_empty())
6672 {
6673 return Some(name.clone());
6674 }
6675 let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
6676 self.container_name_for_lock_id(opens)
6677 }
6678
6679 pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
6681 if stack.template_id == KEY_TEMPLATE {
6682 self.template_display_name(KEY_TEMPLATE)
6683 } else {
6684 stack
6685 .display_name
6686 .clone()
6687 .unwrap_or_else(|| stack.template_id.clone())
6688 }
6689 }
6690
6691 pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
6693 if stack.template_id != KEY_TEMPLATE {
6694 return String::new();
6695 }
6696 match self.key_pair_chest_label(stack) {
6697 Some(chest) if self.key_drop_blocked(stack) => {
6698 format!(" [key for {chest} — can't drop while locked]")
6699 }
6700 Some(chest) => format!(" [key for {chest}]"),
6701 None => " [key — unpaired]".into(),
6702 }
6703 }
6704
6705 pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
6707 for c in &self.placed_containers {
6708 if c.lock_id.as_deref() == Some(lock) {
6709 return Some(c.display_name.clone());
6710 }
6711 }
6712 Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
6713 self.worn
6714 .values()
6715 .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
6716 })
6717 }
6718
6719 pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
6721 if stack.template_id != KEY_TEMPLATE {
6722 return false;
6723 }
6724 let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
6725 return false;
6726 };
6727 for c in &self.placed_containers {
6728 if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
6729 return true;
6730 }
6731 }
6732 if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
6733 return true;
6734 }
6735 self.worn
6736 .values()
6737 .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
6738 }
6739
6740 pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
6742 stack.template_id == PROPERTY_DEED_TEMPLATE
6743 }
6744
6745 pub fn is_property_deed_template(template_id: &str) -> bool {
6746 template_id == PROPERTY_DEED_TEMPLATE
6747 }
6748
6749 pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
6750 stack
6751 .props
6752 .get("plot_id")
6753 .and_then(|s| uuid::Uuid::parse_str(s).ok())
6754 }
6755
6756 pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
6758 let (px, py) = self.player_position();
6759 let (cx, cy) = self.farm_plot_cell_under_player()?;
6760 let tx = cx as f32 + 0.5;
6761 let ty = cy as f32 + 0.5;
6762 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
6763 return None;
6764 }
6765 let kind = self.terrain_at(tx, ty).or_else(|| self.terrain_at(px, py));
6766 if kind == Some(TerrainKindView::Tilled) {
6767 return None;
6768 }
6769 if matches!(
6770 kind,
6771 Some(TerrainKindView::ShallowWater)
6772 | Some(TerrainKindView::DeepWater)
6773 | Some(TerrainKindView::Rock)
6774 ) {
6775 return None;
6776 }
6777 Some((tx, ty))
6778 }
6779
6780 fn container_name_in_stacks(
6781 stacks: &[flatland_protocol::ItemStack],
6782 lock: &str,
6783 ) -> Option<String> {
6784 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
6785 for s in stacks {
6786 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
6787 return Some(GameState::stack_container_label(s));
6788 }
6789 if let Some(name) = walk(&s.contents, lock) {
6790 return Some(name);
6791 }
6792 }
6793 None
6794 }
6795 walk(stacks, lock)
6796 }
6797
6798 fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
6799 stack
6800 .props
6801 .get(PROP_CUSTOM_NAME)
6802 .cloned()
6803 .or_else(|| stack.display_name.clone())
6804 .unwrap_or_else(|| stack.template_id.clone())
6805 }
6806
6807 fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
6808 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
6809 for s in stacks {
6810 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
6811 return true;
6812 }
6813 if walk(&s.contents, lock) {
6814 return true;
6815 }
6816 }
6817 false
6818 }
6819 walk(stacks, lock)
6820 }
6821
6822 fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
6823 if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
6824 return Some(stack.clone());
6825 }
6826 for worn in self.worn.values() {
6827 if worn.item_instance_id == Some(instance_id) {
6828 return Some(worn.clone());
6829 }
6830 if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
6831 return Some(stack.clone());
6832 }
6833 }
6834 None
6835 }
6836
6837 pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
6839 self.property_zones
6840 .iter()
6841 .enumerate()
6842 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
6843 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
6844 .map(|(_, z)| z)
6845 }
6846
6847 pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
6849 self.tax_zones
6850 .iter()
6851 .enumerate()
6852 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
6853 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
6854 .map(|(_, z)| z)
6855 }
6856
6857 pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
6859 let mut max_bps = 0u32;
6860 let mut y = y0 + 0.5;
6861 while y < y1 {
6862 let mut x = x0 + 0.5;
6863 while x < x1 {
6864 if let Some(tz) = self.tax_zone_at(x, y) {
6865 max_bps = max_bps.max(tz.rate_bps);
6866 }
6867 x += 1.0;
6868 }
6869 y += 1.0;
6870 }
6871 max_bps
6872 }
6873
6874 pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
6876 let mode = self.claim_mode.as_ref()?;
6877 let w = mode.width_m.max(1) as f32;
6878 let h = mode.height_m.max(1) as f32;
6879 Some((
6880 mode.anchor_x,
6881 mode.anchor_y,
6882 mode.anchor_x + w,
6883 mode.anchor_y + h,
6884 ))
6885 }
6886
6887 pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
6889 let mode = self.relocate_mode.as_ref()?;
6890 let x0 = mode.cursor_x.floor();
6891 let y0 = mode.cursor_y.floor();
6892 Some((x0, y0, x0 + 1.0, y0 + 1.0))
6893 }
6894
6895 pub fn claim_quote(&self) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
6898 let mode = self.claim_mode.as_ref()?;
6899 let zone = self.property_zones.iter().find(|z| z.id == mode.zone_id)?;
6900 let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
6901 let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
6902 let zone_area = zone_view_area_m2(zone).max(1.0);
6903 let area_frac = (area / zone_area).clamp(0.0, 1.0);
6904 let weight = self
6905 .property_plot_settings
6906 .as_ref()
6907 .map(|s| s.tax_premium_weight)
6908 .unwrap_or(0.5)
6909 .max(0.0);
6910 let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
6911 let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
6912 let purchase = ((zone.crown_price_copper as f64) * (area_frac as f64) * (premium as f64))
6913 .ceil()
6914 .max(0.0) as u64;
6915 let upkeep = if zone.upkeep_copper_per_day == 0 {
6916 0
6917 } else {
6918 ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
6919 .ceil()
6920 .max(1.0) as u64
6921 };
6922 let copper = crate::currency::copper_from_counts(&self.inventory);
6923 let can_afford = copper >= purchase;
6924 let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
6925 Some((purchase, upkeep, area, premium, can_afford, valid, reason))
6926 }
6927
6928 fn validate_claim_footprint(
6929 &self,
6930 zone: &flatland_protocol::PropertyZoneView,
6931 x0: f32,
6932 y0: f32,
6933 x1: f32,
6934 y1: f32,
6935 area: f32,
6936 ) -> (bool, String) {
6937 let min_area = self
6938 .property_plot_settings
6939 .as_ref()
6940 .map(|s| s.min_plot_area_m2)
6941 .unwrap_or(4.0);
6942 if area + f32::EPSILON < min_area {
6943 return (false, "plot too small".into());
6944 }
6945 if zone.max_area_m2.is_some_and(|m| area > m) {
6946 return (false, "plot exceeds max area".into());
6947 }
6948 if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
6949 return (false, "plot must lie inside the property zone".into());
6950 }
6951 if self
6952 .property_plots
6953 .iter()
6954 .any(|p| rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1))
6955 {
6956 return (false, "plot overlaps an existing claim".into());
6957 }
6958 (true, String::new())
6959 }
6960
6961 pub fn free_property_zone_under_player(&self) -> Option<&flatland_protocol::PropertyZoneView> {
6963 let (px, py) = self.player_position();
6964 let zone = self.property_zone_at(px, py)?;
6965 if self.property_plots.iter().any(|p| point_in_plot(px, py, p)) {
6966 return None;
6967 }
6968 Some(zone)
6969 }
6970
6971 pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
6973 let (px, py) = self.player_position();
6974 self.property_plots
6975 .iter()
6976 .find(|p| p.is_mine && point_in_plot(px, py, p))
6977 }
6978
6979 pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
6981 let (px, py) = self.player_position();
6982 self.property_plots
6983 .iter()
6984 .find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
6985 }
6986
6987 pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
6989 if self.farmable_plot_under_player().is_none() {
6990 return None;
6991 }
6992 let (px, py) = self.player_position();
6993 Some((px.floor() as i32, py.floor() as i32))
6994 }
6995
6996 fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
6997 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6998 self.resource_nodes.iter().any(|n| {
6999 let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
7000 ncx == cx && ncy == cy || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
7001 })
7002 }
7003
7004 fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
7005 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
7006 let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
7007 || self
7008 .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
7009 .is_some_and(|z| z.kind == TerrainKindView::Tilled);
7010 if !tilled {
7011 return false;
7012 }
7013 !self.resource_node_occupies_farm_cell(cx, cy)
7014 }
7015
7016 pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
7018 let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
7019 return false;
7020 };
7021 self.free_tilled_plant_slot_at(cx, cy)
7022 }
7023
7024 pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
7026 let (px, py) = self.player_position();
7027 for dy in -2..=2 {
7028 for dx in -2..=2 {
7029 let cx = px.floor() as i32 + dx;
7030 let cy = py.floor() as i32 + dy;
7031 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
7032 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
7033 continue;
7034 }
7035 if self.free_tilled_plant_slot_at(cx, cy) {
7036 return true;
7037 }
7038 }
7039 }
7040 false
7041 }
7042
7043 fn stack_is_farm_seed(&self, stack: &flatland_protocol::ItemStack) -> bool {
7044 if stack.quantity == 0 {
7045 return false;
7046 }
7047 if stack.props.contains_key("seed_for") {
7048 return true;
7049 }
7050 if let Some(entry) = self.item_catalog.get(&stack.template_id) {
7051 if entry.is_farm_seed() {
7052 return true;
7053 }
7054 }
7055 stack.template_id.ends_with("_seed")
7056 }
7057
7058 pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
7060 let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
7061 fn walk(
7062 stacks: &[flatland_protocol::ItemStack],
7063 state: &GameState,
7064 counts: &mut std::collections::HashMap<String, u32>,
7065 ) {
7066 for s in stacks {
7067 if state.stack_is_farm_seed(s) {
7068 *counts.entry(s.template_id.clone()).or_default() += s.quantity;
7069 }
7070 walk(&s.contents, state, counts);
7071 }
7072 }
7073 walk(&self.inventory_stacks, self, &mut counts);
7074 for worn in self.worn.values() {
7075 walk(std::slice::from_ref(worn), self, &mut counts);
7076 }
7077 let mut out: Vec<_> = counts
7078 .into_iter()
7079 .map(|(template_id, quantity)| {
7080 let label = self.template_display_name(&template_id);
7081 (template_id, quantity, label)
7082 })
7083 .collect();
7084 out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
7085 out
7086 }
7087
7088 pub fn first_farm_seed_template(&self) -> Option<String> {
7090 self.farm_seed_entries()
7091 .into_iter()
7092 .next()
7093 .map(|(id, _, _)| id)
7094 }
7095
7096 pub fn clamp_plant_menu(&mut self) {
7097 let n = self.farm_seed_entries().len();
7098 if n == 0 {
7099 self.plant_menu_index = 0;
7100 self.plant_quantity = 1;
7101 return;
7102 }
7103 self.plant_menu_index = self.plant_menu_index.min(n - 1);
7104 let max_qty = self
7105 .farm_seed_entries()
7106 .get(self.plant_menu_index)
7107 .map(|(_, q, _)| *q)
7108 .unwrap_or(1)
7109 .max(1);
7110 self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
7111 }
7112
7113 pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
7114 let entries = self.farm_seed_entries();
7115 let (id, max, label) = entries.get(self.plant_menu_index)?;
7116 let qty = self.plant_quantity.min(*max).max(1);
7117 Some((id.clone(), qty, label.clone()))
7118 }
7119
7120 pub fn location_context_lines(&self) -> Vec<ContextLine> {
7122 let (px, py) = self.player_position();
7123 let inside = self.effective_inside_building();
7124 let mut lines = Vec::new();
7125
7126 if let Some(kind) = self.terrain_at(px, py) {
7127 lines.push(ContextLine {
7128 on_top: true,
7129 text: format!("Terrain: {}", terrain_kind_label(kind)),
7130 });
7131 }
7132
7133 if let Some(id) = inside.as_ref() {
7134 if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
7135 lines.push(ContextLine {
7136 on_top: true,
7137 text: format!("Inside: {}", b.label),
7138 });
7139 }
7140 }
7141
7142 let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
7143
7144 for node in &self.resource_nodes {
7145 if node.id.starts_with("preview:") {
7146 continue;
7147 }
7148 let dist = distance(px, py, node.x, node.y);
7149 if dist > NEARBY_SCAN_M {
7150 continue;
7151 }
7152 let on_top = dist <= ON_TOP_RADIUS_M;
7153 let prefix = if on_top { "On" } else { "Near" };
7154 let name = resource_node_near_display_label(&node.label);
7155 let action = resource_node_near_action_suffix(node);
7156 nearby.push((
7157 dist,
7158 ContextLine {
7159 on_top,
7160 text: format!("{prefix}: {name} ({dist:.1}m){action}"),
7161 },
7162 ));
7163 }
7164
7165 for drop in &self.ground_drops {
7166 let dist = distance(px, py, drop.x, drop.y);
7167 if dist > INTERACTION_RADIUS_M {
7168 continue;
7169 }
7170 let on_top = dist <= ON_TOP_RADIUS_M;
7171 let name = self.template_display_name(&drop.template_id);
7172 let prefix = if on_top { "On" } else { "Near" };
7173 let qty = if drop.quantity > 1 {
7174 format!(" ×{}", drop.quantity)
7175 } else {
7176 String::new()
7177 };
7178 nearby.push((
7179 dist,
7180 ContextLine {
7181 on_top,
7182 text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
7183 },
7184 ));
7185 }
7186
7187 for c in &self.placed_containers {
7188 if !self.placed_container_in_current_space(c) {
7189 continue;
7190 }
7191 let dist = distance(px, py, c.x, c.y);
7192 if dist > CONTAINER_RANGE_M {
7193 continue;
7194 }
7195 let on_top = dist <= ON_TOP_RADIUS_M;
7196 let name = self.placed_container_public_label(c);
7197 let lock = if c.locked { " [locked]" } else { "" };
7198 let prefix = if on_top { "On" } else { "Near" };
7199 nearby.push((
7200 dist,
7201 ContextLine {
7202 on_top,
7203 text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
7204 },
7205 ));
7206 }
7207
7208 for npc in &self.npcs {
7209 let dist = distance(px, py, npc.x, npc.y);
7210 if dist > NEARBY_SCAN_M {
7211 continue;
7212 }
7213 let on_top = dist <= ON_TOP_RADIUS_M;
7214 let prefix = if on_top { "On" } else { "Near" };
7215 nearby.push((
7216 dist,
7217 ContextLine {
7218 on_top,
7219 text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
7220 },
7221 ));
7222 }
7223
7224 for door in &self.doors {
7225 let dist = distance(px, py, door.x, door.y);
7226 if dist > DOOR_INTERACTION_RADIUS_M {
7227 continue;
7228 }
7229 let building = self
7230 .buildings
7231 .iter()
7232 .find(|b| b.id == door.building_id)
7233 .map(|b| b.label.as_str())
7234 .unwrap_or(door.building_id.as_str());
7235 let player_house = self
7236 .buildings
7237 .iter()
7238 .find(|b| b.id == door.building_id)
7239 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
7240 let action = if inside.is_some() && door.portal.is_some() {
7241 if player_house {
7242 if door.locked {
7243 "locked — l unlock · Enter exit".to_string()
7244 } else if door.open {
7245 "close · Enter exit · l lock".to_string()
7246 } else {
7247 "open · Enter exit · l lock".to_string()
7248 }
7249 } else {
7250 "exit".to_string()
7251 }
7252 } else if player_house {
7253 if door.locked {
7254 "locked — l unlock".to_string()
7255 } else if door.open {
7256 "close · Enter go inside · l lock".to_string()
7257 } else {
7258 "open · l lock".to_string()
7259 }
7260 } else {
7261 "enter".to_string()
7262 };
7263 nearby.push((
7264 dist,
7265 ContextLine {
7266 on_top: dist <= ON_TOP_RADIUS_M,
7267 text: format!("{building} door ({dist:.1}m) — f {action}"),
7268 },
7269 ));
7270 }
7271
7272 if inside.is_none() {
7273 for inter in &self.interactables {
7274 if inter.kind != "quest_board" {
7275 continue;
7276 }
7277 let dist = distance(px, py, inter.x, inter.y);
7278 if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
7279 continue;
7280 }
7281 let on_top = dist <= ON_TOP_RADIUS_M;
7282 let prefix = if on_top { "On" } else { "Near" };
7283 let label = if inter.label.is_empty() {
7284 "Quest board".to_string()
7285 } else {
7286 inter.label.clone()
7287 };
7288 nearby.push((
7289 dist,
7290 ContextLine {
7291 on_top,
7292 text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
7293 },
7294 ));
7295 }
7296 }
7297
7298 if self.near_liquid_fill_source() {
7299 let on_water = matches!(
7300 self.terrain_at(px, py),
7301 Some(TerrainKindView::ShallowWater | TerrainKindView::DeepWater)
7302 );
7303 let well = self.buildings.iter().find(|b| {
7304 b.tags.iter().any(|t| t == "well") && {
7305 let hw = b.width_m * 0.5;
7306 let hd = b.depth_m * 0.5;
7307 let nx = px.clamp(b.x - hw, b.x + hw);
7308 let ny = py.clamp(b.y - hd, b.y + hd);
7309 let dx = px - nx;
7310 let dy = py - ny;
7311 dx * dx + dy * dy <= INTERACTION_RADIUS_M * INTERACTION_RADIUS_M
7312 }
7313 });
7314 if let Some(well) = well {
7315 let name = if well.label.trim().is_empty() {
7316 "Well"
7317 } else {
7318 well.label.as_str()
7319 };
7320 nearby.push((
7321 0.0,
7322 ContextLine {
7323 on_top: true,
7324 text: format!("{name} — Use a vessel from inventory to fill"),
7325 },
7326 ));
7327 } else if on_water {
7328 if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
7329 line.text
7330 .push_str(" — Use a vessel from inventory to fill");
7331 }
7332 } else {
7333 nearby.push((
7334 0.0,
7335 ContextLine {
7336 on_top: true,
7337 text: "Water nearby — Use a vessel from inventory to fill".into(),
7338 },
7339 ));
7340 }
7341 }
7342
7343 if self.claim_mode.is_some() {
7344 nearby.push((
7345 0.0,
7346 ContextLine {
7347 on_top: true,
7348 text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
7349 .into(),
7350 },
7351 ));
7352 } else if let Some(plot) = self.my_plot_under_player() {
7353 let name = plot_public_label(plot);
7354 let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
7355 format!("{name} — f again to sell to crown")
7356 } else {
7357 format!(
7358 "{name} — Shift+c till · p plant · f harvest · B build · l door lock · o farm access · Shift+n rename"
7359 )
7360 };
7361 nearby.push((
7362 0.0,
7363 ContextLine {
7364 on_top: true,
7365 text: prompt,
7366 },
7367 ));
7368 } else if let Some(plot) = self.farmable_plot_under_player() {
7369 let name = plot_public_label(plot);
7370 let disc = if plot.farm_public {
7371 plot.public_tax_discount_bps / 100
7372 } else {
7373 plot.farm_allow
7374 .iter()
7375 .find(|g| Some(g.character_id) == self.character_id)
7376 .map(|g| g.tax_discount_bps / 100)
7377 .unwrap_or(0)
7378 };
7379 nearby.push((
7380 0.0,
7381 ContextLine {
7382 on_top: true,
7383 text: format!(
7384 "{name} (farming · tax −{disc}%) — Shift+c till · p plant · f harvest"
7385 ),
7386 },
7387 ));
7388 } else if let Some(zone) = self.free_property_zone_under_player() {
7389 let label = zone
7390 .label
7391 .as_deref()
7392 .filter(|s| !s.trim().is_empty())
7393 .unwrap_or(zone.id.as_str());
7394 nearby.push((
7395 0.0,
7396 ContextLine {
7397 on_top: true,
7398 text: format!("Claimable land: {label} — k buy plot"),
7399 },
7400 ));
7401 }
7402
7403 for entity in &self.entities {
7404 if entity.id == self.entity_id {
7405 continue;
7406 }
7407 let dist = distance(
7408 px,
7409 py,
7410 entity.transform.position.x,
7411 entity.transform.position.y,
7412 );
7413 if dist > NEARBY_SCAN_M {
7414 continue;
7415 }
7416 let label = if entity.label.is_empty() {
7417 format!("entity {}", entity.id)
7418 } else {
7419 entity.label.clone()
7420 };
7421 nearby.push((
7422 dist,
7423 ContextLine {
7424 on_top: dist <= ON_TOP_RADIUS_M,
7425 text: format!("Near: {label} ({dist:.1}m)"),
7426 },
7427 ));
7428 }
7429
7430 nearby.sort_by(|a, b| {
7431 a.0.partial_cmp(&b.0)
7432 .unwrap_or(std::cmp::Ordering::Equal)
7433 .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
7434 });
7435 lines.extend(nearby.into_iter().map(|(_, l)| l));
7436
7437 if lines.is_empty() {
7438 lines.push(ContextLine {
7439 on_top: false,
7440 text: "(nothing notable nearby)".into(),
7441 });
7442 }
7443
7444 lines
7445 }
7446}
7447
7448#[derive(Debug, Clone)]
7450pub struct ContextLine {
7451 pub on_top: bool,
7452 pub text: String,
7453}
7454
7455const ON_TOP_RADIUS_M: f32 = 0.65;
7456const NEARBY_SCAN_M: f32 = 5.0;
7457
7458pub fn resource_node_near_display_label(label: &str) -> String {
7460 label
7461 .strip_suffix(" (growing)")
7462 .unwrap_or(label)
7463 .to_string()
7464}
7465
7466fn resource_label_looks_like_raw_id(label: &str, id: &str) -> bool {
7467 let t = label.trim();
7468 if t.is_empty() || t == id {
7469 return true;
7470 }
7471 let lower = t.to_ascii_lowercase();
7472 if lower.contains("_copy") {
7473 return true;
7474 }
7475 false
7476}
7477
7478fn humanize_item_template_label(template: &str) -> String {
7479 let base = template.rsplit('/').next().unwrap_or(template).trim();
7480 if base.is_empty() {
7481 return "Resource".into();
7482 }
7483 let stripped = base
7484 .strip_prefix("crop-")
7485 .or_else(|| base.strip_prefix("crop_"))
7486 .unwrap_or(base);
7487 stripped
7488 .split(|c: char| c == '-' || c == '_')
7489 .filter(|p| !p.is_empty())
7490 .map(|p| {
7491 let mut chars = p.chars();
7492 match chars.next() {
7493 Some(c) => format!("{}{}", c.to_ascii_uppercase(), chars.as_str()),
7494 None => String::new(),
7495 }
7496 })
7497 .collect::<Vec<_>>()
7498 .join(" ")
7499}
7500
7501pub fn resource_node_id_suffix(id: &str) -> String {
7503 let chars: Vec<char> = id
7504 .chars()
7505 .rev()
7506 .filter(|c| c.is_ascii_alphanumeric())
7507 .take(4)
7508 .collect();
7509 chars.into_iter().rev().collect()
7510}
7511
7512pub fn resource_node_route_label(node: &flatland_protocol::ResourceNodeView) -> String {
7514 resource_node_route_label_parts(&node.id, &node.label, &node.item_template)
7515}
7516
7517pub fn resource_node_route_label_parts(id: &str, label: &str, item_template: &str) -> String {
7518 let cleaned = resource_node_near_display_label(label);
7519 let friendly = if !resource_label_looks_like_raw_id(&cleaned, id) {
7520 cleaned
7521 } else if !item_template.trim().is_empty() {
7522 humanize_item_template_label(item_template)
7523 } else {
7524 id.to_string()
7525 };
7526 let suffix = resource_node_id_suffix(id);
7527 if suffix.is_empty() {
7528 friendly
7529 } else {
7530 format!("{friendly} ({suffix})")
7531 }
7532}
7533
7534pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
7536 use flatland_protocol::ResourceNodeState;
7537 if node.harvest_off {
7538 return " (decorative)".to_string();
7539 }
7540 if let Some(p) = node.growth_progress {
7541 if p < 1.0 - f32::EPSILON {
7542 let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
7543 return format!(" (growing, {pct}%)");
7544 }
7545 return " — f harvest".to_string();
7546 }
7547 match node.state {
7548 ResourceNodeState::Available => " — f harvest".to_string(),
7549 ResourceNodeState::Harvesting => " (being harvested)".to_string(),
7550 ResourceNodeState::Cooldown => " (depleted)".to_string(),
7551 }
7552}
7553
7554fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
7555 use flatland_protocol::TerrainKindView;
7556 match kind {
7557 TerrainKindView::Grass => "Grass",
7558 TerrainKindView::Dirt => "Dirt",
7559 TerrainKindView::Tilled => "Tilled",
7560 TerrainKindView::Desert => "Desert",
7561 TerrainKindView::Hill => "Hills",
7562 TerrainKindView::Bog => "Bog",
7563 TerrainKindView::Beach => "Beach",
7564 TerrainKindView::ShallowWater => "Shallow water",
7565 TerrainKindView::DeepWater => "Deep water",
7566 TerrainKindView::Trail => "Trail",
7567 TerrainKindView::Road => "Road",
7568 TerrainKindView::Rock => "Rock",
7569 }
7570}
7571
7572fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
7573 crate::world_zones::zone_rects_contain(rects, x, y)
7574}
7575
7576fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
7577 zone.rects
7578 .iter()
7579 .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
7580 .sum()
7581}
7582
7583fn claim_rect_fully_inside_zone(
7584 zone: &flatland_protocol::PropertyZoneView,
7585 x0: f32,
7586 y0: f32,
7587 x1: f32,
7588 y1: f32,
7589) -> bool {
7590 let mut y = y0 + 0.5;
7591 while y < y1 {
7592 let mut x = x0 + 0.5;
7593 while x < x1 {
7594 if !zone_rects_contain(&zone.rects, x, y) {
7595 return false;
7596 }
7597 x += 1.0;
7598 }
7599 y += 1.0;
7600 }
7601 true
7602}
7603
7604fn rects_overlap_half_open(
7605 ax0: f32,
7606 ay0: f32,
7607 ax1: f32,
7608 ay1: f32,
7609 bx0: f32,
7610 by0: f32,
7611 bx1: f32,
7612 by1: f32,
7613) -> bool {
7614 ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
7615}
7616
7617fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
7618 x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
7619}
7620
7621fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
7622 plot_public_label(p)
7623}
7624
7625fn plot_size_fallback_label(p: &flatland_protocol::PropertyPlotView) -> String {
7626 let w = (p.x1 - p.x0).abs();
7627 let d = (p.y1 - p.y0).abs();
7628 format!("Plot ({w:.0}×{d:.0} m)")
7629}
7630
7631pub fn plot_public_label(p: &flatland_protocol::PropertyPlotView) -> String {
7633 let zone = p
7634 .zone_label
7635 .as_deref()
7636 .filter(|s| !s.trim().is_empty())
7637 .unwrap_or_else(|| {
7638 if p.property_zone_id.is_empty() {
7639 "Homestead"
7640 } else {
7641 p.property_zone_id.as_str()
7642 }
7643 });
7644 let label = if !p.label.trim().is_empty() {
7645 p.label.clone()
7646 } else if !p.plot_code.trim().is_empty() {
7647 p.plot_code.clone()
7648 } else {
7649 plot_size_fallback_label(p)
7650 };
7651 match p
7652 .owner_label
7653 .as_deref()
7654 .map(str::trim)
7655 .filter(|s| !s.is_empty())
7656 {
7657 Some(owner) => format!("{owner} — {zone} — {label}"),
7658 None => format!("{zone} — {label}"),
7659 }
7660}
7661
7662pub fn plot_stop_label(
7667 plots: &[flatland_protocol::PropertyPlotView],
7668 plot_id: uuid::Uuid,
7669) -> String {
7670 plots
7671 .iter()
7672 .find(|p| p.plot_id == plot_id)
7673 .map(plot_public_label)
7674 .unwrap_or_else(|| {
7675 let s = plot_id.to_string();
7676 format!("plot {}", s.get(..8).unwrap_or(s.as_str()))
7677 })
7678}
7679
7680fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
7682 let a = x0.min(x1).floor();
7683 let b = y0.min(y1).floor();
7684 let mut c = x0.max(x1).ceil();
7685 let mut d = y0.max(y1).ceil();
7686 if (c - a) < 1.0 {
7687 c = a + 1.0;
7688 }
7689 if (d - b) < 1.0 {
7690 d = b + 1.0;
7691 }
7692 (a, b, c, d)
7693}
7694
7695fn humanize_template_id(template_id: &str) -> String {
7696 if looks_like_template_uuid(template_id) {
7698 return "Unknown item".into();
7699 }
7700 template_id
7701 .split('_')
7702 .map(|word| {
7703 let mut chars = word.chars();
7704 match chars.next() {
7705 None => String::new(),
7706 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
7707 }
7708 })
7709 .collect::<Vec<_>>()
7710 .join(" ")
7711}
7712
7713fn looks_like_template_uuid(template_id: &str) -> bool {
7714 let bytes = template_id.as_bytes();
7715 if bytes.len() != 36 {
7716 return false;
7717 }
7718 let is_hex = |b: u8| b.is_ascii_hexdigit();
7719 let groups = [8usize, 4, 4, 4, 12];
7720 let mut i = 0;
7721 for (gi, &len) in groups.iter().enumerate() {
7722 if gi > 0 {
7723 if bytes.get(i) != Some(&b'-') {
7724 return false;
7725 }
7726 i += 1;
7727 }
7728 for _ in 0..len {
7729 if !bytes.get(i).copied().is_some_and(is_hex) {
7730 return false;
7731 }
7732 i += 1;
7733 }
7734 }
7735 true
7736}
7737
7738const HARVEST_RANGE_M: f32 = 1.5;
7740
7741pub struct GameClient<S: PlayConnection> {
7742 session: S,
7743 seq: Seq,
7744 pub state: GameState,
7745 last_move_forward: f32,
7746 last_move_strafe: f32,
7747}
7748
7749impl<S: PlayConnection> GameClient<S> {
7750 pub fn new(session: S) -> Self {
7751 let session_id = session.session_id();
7752 let entity_id = session.entity_id();
7753 let mut client = Self {
7754 session,
7755 seq: 0,
7756 last_move_forward: 0.0,
7757 last_move_strafe: 0.0,
7758 state: GameState {
7759 session_id,
7760 entity_id,
7761 character_id: None,
7762 tick: 0,
7763 chunk_rev: 0,
7764 content_rev: 0,
7765 publish_rev: 0,
7766 entities: Vec::new(),
7767 player: None,
7768 resource_nodes: Vec::new(),
7769 harvest_route_nodes: Vec::new(),
7770 ground_drops: Vec::new(),
7771 placed_containers: Vec::new(),
7772 buildings: Vec::new(),
7773 doors: Vec::new(),
7774 interior_map: None,
7775 npcs: Vec::new(),
7776 blueprints: Vec::new(),
7777 building_materials: Vec::new(),
7778 world_x0: 0.0,
7779 world_y0: 0.0,
7780 world_width_m: 0.0,
7781 world_height_m: 0.0,
7782 terrain_zones: Vec::new(),
7783 z_platforms: Vec::new(),
7784 z_transitions: Vec::new(),
7785 z_bands_outdoor_backup: None,
7786 world_clock: flatland_protocol::WorldClock::default(),
7787 inventory: std::collections::HashMap::new(),
7788 inventory_hints: std::collections::HashMap::new(),
7789 item_catalog: std::collections::HashMap::new(),
7790 logs: VecDeque::new(),
7791 intents_sent: 0,
7792 ticks_received: 0,
7793 connected: false,
7794 disconnect_reason: None,
7795 show_stats: false,
7796 hud_log_hidden: false,
7797 show_equip_menu: false,
7798 equip_menu_index: 0,
7799 show_craft_menu: false,
7800 show_plot_build_menu: false,
7801 plot_build_focus_wall: true,
7802 plot_build_wall_index: 0,
7803 plot_build_roof_index: 0,
7804 craft_menu_index: 0,
7805 craft_batch_quantity: 1,
7806 craft_tab: CraftTab::Ready,
7807 craft_filter: String::new(),
7808 craft_filter_focused: false,
7809 craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
7810 show_shop_menu: false,
7811 shop_catalog: None,
7812 bank_panel: None,
7813 bank_menu_index: 0,
7814 bank_ui_mode: BankUiMode::Menu,
7815 storage_panel: None,
7816 market_panel: None,
7817 market_menu_index: 0,
7818 market_filter: String::new(),
7819 market_filter_focused: false,
7820 market_category_filter: None,
7821 market_buy_confirm: None,
7822 market_ui_mode: MarketUiMode::Browse,
7823 storage_menu_index: 0,
7824 storage_ui_mode: StorageUiMode::Menu,
7825 shop_tab: ShopTab::default(),
7826 shop_menu_index: 0,
7827 shop_quantity: 1,
7828 shop_trade_log: VecDeque::new(),
7829 show_npc_verb_menu: false,
7830 npc_verb_target: None,
7831 npc_verb_index: 0,
7832 npc_verb_notice: None,
7833 player_verbs: crate::social::PlayerVerbState::default(),
7834 social_chat: crate::social::SocialChatState::default(),
7835 trade_ui: crate::social::TradeUiState::default(),
7836 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
7837 show_npc_chat: false,
7838 npc_chat: None,
7839 show_inventory_menu: false,
7840 inventory_menu_index: 0,
7841 inventory_tab: InventoryTab::OnPerson,
7842 inventory_filter: String::new(),
7843 inventory_filter_focused: false,
7844 show_move_picker: false,
7845 show_rename_prompt: false,
7846 rename_plot_id: None,
7847 highlighted_plot_id: None,
7848 show_worker_rename: false,
7849 rename_buffer: String::new(),
7850 move_picker_index: 0,
7851 move_picker: None,
7852 show_grant_picker: false,
7853 grant_picker_index: 0,
7854 grant_picker: None,
7855 show_destroy_picker: false,
7856 destroy_confirm_pending: false,
7857 destroy_picker: None,
7858 combat_target: None,
7859 combat_target_label: None,
7860 ground_target: None,
7861 combat_fx: Vec::new(),
7862 ground_hazards: Vec::new(),
7863 property_zones: Vec::new(),
7864 tax_zones: Vec::new(),
7865 growth_zones: Vec::new(),
7866 biome_zones: Vec::new(),
7867 terrain_kind_nav: Vec::new(),
7868 property_plots: Vec::new(),
7869 property_plot_settings: None,
7870 claim_mode: None,
7871 relocate_mode: None,
7872 sell_plot_confirm: None,
7873 sell_plot_armed_at: None,
7874 show_plant_menu: false,
7875 plant_menu_index: 0,
7876 show_farm_access: false,
7877 farm_access_name_draft: String::new(),
7878 farm_access_discount_bps: 0,
7879 farm_access_index: 0,
7880 plant_quantity: 1,
7881 in_combat: false,
7882 auto_attack: true,
7883 combat_has_los: false,
7884 attack_cd_ticks: 0,
7885 gcd_ticks: 0,
7886 weapon_ability_id: "unarmed".into(),
7887 mainhand_template_id: None,
7888 mainhand_label: None,
7889 mainhand_instance_id: None,
7890 offhand_template_id: None,
7891 offhand_label: None,
7892 offhand_instance_id: None,
7893 mainhand_hand_slots: 1,
7894 defense: None,
7895 worn: BTreeMap::new(),
7896 carry_mass: 0.0,
7897 carry_mass_max: 0.0,
7898 encumbrance: flatland_protocol::EncumbranceState::Light,
7899 move_speed_mps: 0.0,
7900 move_speed_mult: 0.0,
7901 inventory_stacks: Vec::new(),
7902 keychain_stacks: Vec::new(),
7903 whisper_pouch_stacks: Vec::new(),
7904 combat_target_detail: None,
7905 statuses: Vec::new(),
7906 cast_progress: None,
7907 timed_channel: None,
7908 plot_build_offer: None,
7909 ability_cooldowns: Vec::new(),
7910 blocking_active: false,
7911 max_target_slots: 1,
7912 combat_slots: Vec::new(),
7913 rotation_presets: Vec::new(),
7914 known_abilities: Vec::new(),
7915 ability_meta: std::collections::HashMap::new(),
7916 ability_mastery: std::collections::HashMap::new(),
7917 hotbar: vec![None; 9],
7918 max_abilities_per_rotation: 0,
7919 show_loadout_menu: false,
7920 show_keychain_menu: false,
7921 keychain_menu_index: 0,
7922 show_rotation_editor: false,
7923 loadout_menu_index: 0,
7924 loadout_hotbar_slot: 1,
7925 loadout_ability_index: 0,
7926 loadout_focus_presets: false,
7927 rotation_editor: RotationEditorState::default(),
7928 harvest_in_progress: false,
7929 harvest_started_at: None,
7930 pending_craft_ack: None,
7931 craft_channel_blueprint_id: None,
7932 pending_worker_job_ack: None,
7933 attending_worker_instance_id: None,
7934 quest_log: Vec::new(),
7935 interactables: Vec::new(),
7936 ledger: None,
7937 career: None,
7938 character_sheet_tab: CharacterSheetTab::Character,
7939 ledger_period: LedgerPeriod::Day,
7940 show_quest_offer: false,
7941 pending_quest_offers: Vec::new(),
7942 quest_offer_index: 0,
7943 show_quest_menu: false,
7944 quest_menu_index: 0,
7945 quest_withdraw_confirm: false,
7946 hired_workers: Vec::new(),
7947 show_workers_menu: false,
7948 workers_menu_index: 0,
7949 worker_dismiss_confirmation: None,
7950 workers_menu_compact: false,
7951 worker_step_display: BTreeMap::new(),
7952 worker_error_display: BTreeMap::new(),
7953 worker_health_ring_until: BTreeMap::new(),
7954 pending_worker_hire_since: None,
7955 show_worker_give_picker: false,
7956 worker_give_picker_index: 0,
7957 worker_give_picker: None,
7958 show_worker_give_target_picker: false,
7959 worker_give_target_picker_index: 0,
7960 worker_give_target_picker: None,
7961 show_worker_take_picker: false,
7962 worker_take_picker_index: 0,
7963 worker_take_picker: None,
7964 show_worker_teach_picker: false,
7965 worker_teach_picker_index: 0,
7966 worker_teach_picker: None,
7967 worker_route_editor: None,
7968 progression_curve: None,
7969 },
7970 };
7971 client.state.apply_client_ui_prefs();
7972 client
7973 }
7974
7975 pub fn entity_id(&self) -> EntityId {
7976 self.state.entity_id
7977 }
7978
7979 pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
7980 if self.state.connected {
7981 return Ok(());
7982 }
7983
7984 loop {
7985 match self.session.next_event().await {
7986 Some(SessionEvent::Welcome {
7987 session_id,
7988 entity_id,
7989 snapshot,
7990 }) => {
7991 self.state
7992 .restore_from_welcome(session_id, entity_id, &snapshot);
7993 self.state.apply_client_ui_prefs();
7994 self.state.push_log(format!(
7995 "Connected — session {session_id}, entity {entity_id}"
7996 ));
7997 return Ok(());
7998 }
7999 Some(SessionEvent::Disconnected { .. }) => {
8000 anyhow::bail!("disconnected before welcome");
8001 }
8002 Some(_) => continue,
8003 None => anyhow::bail!("session closed before welcome"),
8004 }
8005 }
8006 }
8007
8008 pub fn drain_events(&mut self) {
8010 while let Some(event) = self.session.try_next_event() {
8011 if self.handle_event_sync(event).is_err() {
8012 break;
8013 }
8014 }
8015 }
8016
8017 pub async fn next_event(&mut self) -> Option<SessionEvent> {
8019 self.session.next_event().await
8020 }
8021
8022 pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
8023 self.handle_event_sync(event)
8024 }
8025
8026 fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
8027 match event {
8028 SessionEvent::Welcome {
8029 session_id,
8030 entity_id,
8031 snapshot,
8032 } => {
8033 let resumed = self.state.connected;
8034 self.state
8035 .restore_from_welcome(session_id, entity_id, &snapshot);
8036 if resumed {
8037 self.state.push_log(format!(
8038 "Session restored — session {session_id}, entity {entity_id}"
8039 ));
8040 }
8041 }
8042 SessionEvent::ContentUpdated { snapshot } => {
8043 self.state
8044 .apply_snapshot_fields(&snapshot, self.state.entity_id);
8045 self.state.push_log(format!(
8046 "World updated (content rev {})",
8047 snapshot.content_rev
8048 ));
8049 }
8050 SessionEvent::QuestCatalogUpdated(update) => {
8051 self.state.push_log(format!(
8052 "Quest board updated (revision {}, {} new, {} retired)",
8053 update.revision,
8054 update.accepted.len(),
8055 update.retired.len()
8056 ));
8057 }
8058 SessionEvent::Tick(delta) => {
8059 self.state.apply_tick_fields(&delta, self.state.entity_id);
8060 self.state.ticks_received += 1;
8061 }
8062 SessionEvent::IntentAck {
8063 entity_id,
8064 seq,
8065 tick,
8066 } => {
8067 crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
8068 if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
8069 if *craft_seq == seq {
8070 let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
8071 if batches > 1 {
8072 self.state.push_log(format!("Crafting {label} ×{batches}…"));
8073 } else {
8074 self.state.push_log(format!("Crafting {label}…"));
8075 }
8076 }
8077 }
8078 if self
8079 .state
8080 .pending_worker_job_ack
8081 .as_ref()
8082 .is_some_and(|p| p.seq == seq)
8083 {
8084 let pending = self.state.pending_worker_job_ack.take().unwrap();
8085 if pending.idle {
8086 self.state.push_log(format!(
8087 "Route cleared for {} — worker idle",
8088 pending.worker_label
8089 ));
8090 } else {
8091 self.state.push_log(format!(
8092 "Route saved for {} — {} stop(s), job loop active",
8093 pending.worker_label, pending.stop_count
8094 ));
8095 }
8096 if self
8097 .state
8098 .worker_route_editor
8099 .as_ref()
8100 .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
8101 {
8102 self.close_worker_route_editor();
8103 }
8104 }
8105 }
8106 SessionEvent::Chat(msg) => {
8107 let label = match msg.channel {
8108 flatland_protocol::ChatChannel::Nearby => "nearby",
8109 flatland_protocol::ChatChannel::Direct => "speak",
8110 flatland_protocol::ChatChannel::Whisper => "whisper",
8111 flatland_protocol::ChatChannel::WhisperStone => "stone",
8112 };
8113 let clarity = match msg.clarity {
8114 flatland_protocol::ChatClarity::Clear => "",
8115 flatland_protocol::ChatClarity::Partial => "~",
8116 flatland_protocol::ChatClarity::Heavy => "…",
8117 };
8118 self.state.push_log(format!(
8119 "[{label}{clarity}] {}: {}",
8120 msg.from_name, msg.text
8121 ));
8122 let now_ms = std::time::SystemTime::now()
8123 .duration_since(std::time::UNIX_EPOCH)
8124 .map(|d| d.as_millis() as u64)
8125 .unwrap_or(0);
8126 self.state
8127 .social_chat
8128 .note_speech(&msg, self.state.entity_id, now_ms);
8129 self.state
8130 .social_chat
8131 .push(crate::social::ChatLogEntry::from_message(
8132 msg,
8133 self.state.entity_id,
8134 ));
8135 }
8136 SessionEvent::TradeOpened(panel) => {
8137 self.state.social_chat.pending_trade = None;
8138 let peer = panel.peer_name.clone();
8139 self.state.trade_ui.open(panel);
8140 self.state.social_chat.push_system(format!(
8141 "Trade open with {peer} — p present · r ready · Esc cancel"
8142 ));
8143 self.state
8144 .social_chat
8145 .push_cue(crate::social::AudioCue::TradeOpened);
8146 }
8147 SessionEvent::TradeClosed { reason } => {
8148 self.state.push_log(reason.clone());
8149 self.state.social_chat.push_system(reason);
8150 self.state.trade_ui.close();
8151 }
8152 SessionEvent::HarvestResult(result) => {
8153 self.state.clear_harvest_state();
8154 crate::harvest_trace!(
8155 entity_id = self.state.entity_id,
8156 node_id = %result.node_id,
8157 template = %result.item_template,
8158 quantity = result.quantity,
8159 client_tick = self.state.tick,
8160 "client applied harvest result"
8161 );
8162 let msg = if result.quantity == 0 {
8163 format!(
8164 "Harvested {} x0 — nothing dropped (loot table rolled empty)",
8165 result.item_template
8166 )
8167 } else {
8168 format!(
8169 "Harvested {} x{} (on the ground — press P to pick up)",
8170 result.item_template, result.quantity
8171 )
8172 };
8173 self.state.push_log(msg);
8174 }
8175 SessionEvent::CraftResult(result) => {
8176 for stack in &result.consumed {
8177 if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
8178 *qty = qty.saturating_sub(stack.quantity);
8179 if *qty == 0 {
8180 self.state.inventory.remove(&stack.template_id);
8181 }
8182 }
8183 }
8184 for stack in &result.outputs {
8185 *self
8186 .state
8187 .inventory
8188 .entry(stack.template_id.clone())
8189 .or_insert(0) += stack.quantity;
8190 }
8191 self.state.craft_record_completed(&result.blueprint_id);
8192 if let Some(output) = result.outputs.first() {
8193 if result.batch_total > 1 {
8194 self.state.push_log(format!(
8195 "Crafted {} x{} ({}/{})",
8196 output.template_id,
8197 output.quantity,
8198 result.batch_index,
8199 result.batch_total
8200 ));
8201 } else {
8202 self.state.push_log(format!(
8203 "Crafted {} x{}",
8204 output.template_id, output.quantity
8205 ));
8206 }
8207 } else {
8208 self.state
8209 .push_log(format!("Craft finished: {}", result.blueprint_id));
8210 }
8211 }
8212 SessionEvent::Death(notice) => {
8213 self.state.clear_harvest_state();
8214 self.state.push_log(notice.message.clone());
8215 self.state.push_log(format!(
8216 "Respawned at ({:.1}, {:.1})",
8217 notice.respawn_x, notice.respawn_y
8218 ));
8219 }
8220 SessionEvent::Interaction(notice) => {
8221 if notice.message.starts_with("Harvest failed:") {
8222 self.state.clear_harvest_state();
8223 }
8224 if notice.message.starts_with("Can't do that:") {
8225 self.state.pending_worker_hire_since = None;
8226 self.state.pending_craft_ack = None;
8227 self.state.craft_channel_blueprint_id = None;
8228 if let Some(pending) = self.state.pending_worker_job_ack.take() {
8229 if let Some(w) = self
8230 .state
8231 .hired_workers
8232 .iter_mut()
8233 .find(|w| w.instance_id == pending.worker_instance_id)
8234 {
8235 w.route = pending.prev_route;
8236 w.mode = pending.prev_mode;
8237 w.step_label = pending.prev_step_label;
8238 w.last_error = pending.prev_last_error;
8239 }
8240 let reason = notice
8241 .message
8242 .strip_prefix("Can't do that:")
8243 .unwrap_or(¬ice.message)
8244 .trim();
8245 self.state.push_log(format!(
8246 "Route save failed for {}: {reason}",
8247 pending.worker_label
8248 ));
8249 }
8250 let reason = notice
8251 .message
8252 .strip_prefix("Can't do that:")
8253 .unwrap_or(¬ice.message)
8254 .trim();
8255 if reason.contains("already tilled") {
8256 if let Some(plot) = self.state.my_plot_under_player() {
8257 self.state.sell_plot_confirm = Some(plot.plot_id);
8258 self.state.sell_plot_armed_at = Some(Instant::now());
8259 }
8260 }
8261 }
8262 if notice.message.starts_with("Cast failed:") {
8263 self.state.cast_progress = None;
8264 }
8265 if notice.message.contains("slain the") {
8266 self.state.combat_target = None;
8267 self.state.combat_target_label = None;
8268 }
8269 if notice.message.contains("wants to trade") {
8271 if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
8272 let from_name = notice
8273 .message
8274 .split(" wants to trade")
8275 .next()
8276 .unwrap_or("Player")
8277 .to_string();
8278 self.state.social_chat.pending_trade =
8279 Some(crate::social::PendingTradeRequest {
8280 from_entity,
8281 from_name: from_name.clone(),
8282 });
8283 self.state.social_chat.push_system(format!(
8284 "{from_name} wants to trade — [Y] accept · [N] decline"
8285 ));
8286 self.state
8287 .social_chat
8288 .push_cue(crate::social::AudioCue::TradeOffer);
8289 }
8290 }
8291 if notice.message.starts_with("trade request declined") {
8292 self.state.social_chat.push_system(notice.message.clone());
8293 self.state
8294 .social_chat
8295 .push_cue(crate::social::AudioCue::TradeDeclined);
8296 }
8297 if self.state.show_npc_verb_menu && !notice.message.trim().is_empty() {
8299 self.state.npc_verb_notice = Some(notice.message.clone());
8300 self.state
8301 .social_chat
8302 .push_cue(crate::social::AudioCue::UiError);
8303 }
8304 self.state.apply_interaction_notice(¬ice);
8305 self.state.push_log(notice.message.clone());
8306 }
8307 SessionEvent::ShopOpened(catalog) => {
8308 self.state.apply_shop_catalog(catalog);
8309 }
8310 SessionEvent::BankOpened(panel) => {
8311 self.state.apply_bank_panel(panel);
8312 }
8313 SessionEvent::StorageOpened(panel) => {
8314 self.state.apply_storage_panel(panel);
8315 }
8316 SessionEvent::MarketOpened(panel) => {
8317 self.state.apply_market_panel(panel);
8318 }
8319 SessionEvent::NpcTalkOpened(opened) => {
8320 self.state.show_npc_verb_menu = false;
8321 if self.state.npc_verb_target.is_none() {
8322 self.state.npc_verb_target = Some(opened.npc_id.clone());
8323 }
8324 let label = opened.npc_label.clone();
8325 let banner = if !opened.trade_allowed {
8326 Some("Trade is unavailable right now.".to_string())
8327 } else {
8328 None
8329 };
8330 self.state.show_npc_chat = true;
8331 self.state.npc_chat = Some(NpcChatState {
8332 npc_id: opened.npc_id,
8333 npc_label: opened.npc_label,
8334 lines: if opened.greeting.is_empty() {
8335 vec![]
8336 } else {
8337 vec![format!("{label}: {}", opened.greeting)]
8338 },
8339 input: String::new(),
8340 pending: opened.greeting.is_empty(),
8341 talk_depth: opened.talk_depth,
8342 trade_allowed: opened.trade_allowed,
8343 banner,
8344 suggested_topics: opened.suggested_topics,
8345 });
8346 }
8347 SessionEvent::NpcTalkPending(_) => {
8348 if let Some(chat) = self.state.npc_chat.as_mut() {
8349 chat.pending = true;
8350 }
8351 }
8352 SessionEvent::NpcTalkReply(reply) => {
8353 if let Some(chat) = self.state.npc_chat.as_mut() {
8354 if chat.npc_id == reply.npc_id {
8355 chat.pending = false;
8356 if reply.trade_disabled {
8357 chat.trade_allowed = false;
8358 chat.banner = Some("Trade is unavailable right now.".to_string());
8359 }
8360 if reply.wind_down {
8361 chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
8362 if chat.banner.is_none() {
8363 chat.banner =
8364 Some("They're wrapping up — keep it brief.".to_string());
8365 }
8366 }
8367 chat.lines
8368 .push(format!("{}: {}", chat.npc_label, reply.line));
8369 }
8370 }
8371 }
8372 SessionEvent::NpcTalkClosed(closed) => {
8373 if self
8374 .state
8375 .npc_chat
8376 .as_ref()
8377 .is_some_and(|c| c.npc_id == closed.npc_id)
8378 {
8379 self.state.show_npc_chat = false;
8380 self.state.npc_chat = None;
8381 }
8382 }
8383 SessionEvent::NpcTalkError(err) => {
8384 self.state.push_log(format!("Talk failed: {}", err.reason));
8385 if let Some(chat) = self.state.npc_chat.as_mut() {
8386 chat.pending = false;
8387 }
8388 }
8389 SessionEvent::UseResult(result) => {
8390 if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
8393 *qty = qty.saturating_sub(1);
8394 if *qty == 0 {
8395 self.state.inventory.remove(&result.template_id);
8396 }
8397 }
8398 }
8399 SessionEvent::QuestOffer(offer) => {
8400 let title = offer.title.clone();
8401 self.state.push_quest_offer(offer);
8402 self.state.push_log(format!("Quest offered: {title}"));
8403 }
8404 SessionEvent::QuestAccepted(notice) => {
8405 self.state.remove_quest_offer(¬ice.quest_id);
8406 self.state.push_log(notice.message);
8407 }
8408 SessionEvent::QuestWithdrawn(notice) => {
8409 self.state.show_quest_menu = false;
8410 self.state.quest_withdraw_confirm = false;
8411 self.state.push_log(notice.message);
8412 }
8413 SessionEvent::QuestStepCompleted(notice) => {
8414 self.state.push_log(notice.message);
8415 }
8416 SessionEvent::QuestCompleted(notice) => {
8417 self.state.push_log(notice.message);
8418 }
8419 SessionEvent::Disconnected { reason } => {
8420 self.state.clear_harvest_state();
8421 self.state.connected = false;
8422 self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
8423 if let Some(r) = &self.state.disconnect_reason {
8424 self.state.push_log(format!("Disconnected: {r}"));
8425 } else {
8426 self.state.push_log("Disconnected from server");
8427 }
8428 }
8429 }
8430 Ok(())
8431 }
8432
8433 pub fn is_connected(&self) -> bool {
8434 self.state.connected
8435 }
8436
8437 pub fn close_overlays(&mut self) {
8438 self.state.show_stats = false;
8439 self.state.show_craft_menu = false;
8440 self.state.show_plot_build_menu = false;
8441 self.state.show_shop_menu = false;
8442 self.state.shop_catalog = None;
8443 self.state.show_npc_verb_menu = false;
8444 self.state.npc_verb_target = None;
8445 self.state.show_npc_chat = false;
8446 self.state.npc_chat = None;
8447 self.state.show_inventory_menu = false;
8448 self.state.show_loadout_menu = false;
8449 self.state.show_rotation_editor = false;
8450 self.state.rotation_editor.reset();
8451 self.state.show_rename_prompt = false;
8452 self.state.show_worker_rename = false;
8453 self.state.rename_buffer.clear();
8454 self.state.show_move_picker = false;
8455 self.state.move_picker = None;
8456 self.state.show_destroy_picker = false;
8457 self.state.destroy_confirm_pending = false;
8458 self.state.destroy_picker = None;
8459 self.state.show_quest_offer = false;
8460 self.state.clear_quest_offers();
8461 self.state.show_quest_menu = false;
8462 self.state.quest_withdraw_confirm = false;
8463 self.state.show_workers_menu = false;
8464 self.close_worker_give_picker();
8465 self.close_worker_give_target_picker();
8466 self.close_worker_take_picker();
8467 self.close_worker_teach_picker();
8468 self.state.worker_route_editor = None;
8469 self.state.claim_mode = None;
8470 self.state.relocate_mode = None;
8471 self.state.sell_plot_confirm = None;
8472 self.state.sell_plot_armed_at = None;
8473 self.close_farm_access_panel();
8474 if self.state.show_plant_menu {
8475 self.close_plant_menu();
8476 }
8477 }
8478
8479 pub fn back_on_esc(&mut self) -> bool {
8481 if self.state.social_chat.composer_open() {
8482 self.state.social_chat.close_composer();
8483 return true;
8484 }
8485 if self.state.player_verbs.open {
8486 self.state.player_verbs.close();
8487 return true;
8488 }
8489 if self.state.whisper_pouch_ui.open {
8490 self.state.whisper_pouch_ui.open = false;
8491 return true;
8492 }
8493 if self.state.trade_ui.panel.is_some() {
8494 self.state.trade_ui.close();
8496 return true;
8497 }
8498 if self.state.show_rename_prompt {
8499 self.cancel_rename_prompt();
8500 return true;
8501 }
8502 if self.state.show_worker_rename {
8503 self.cancel_worker_rename();
8504 return true;
8505 }
8506 if self.state.show_destroy_picker {
8507 if self.state.destroy_confirm_pending {
8508 self.cancel_destroy_confirm();
8509 } else {
8510 self.close_destroy_picker();
8511 }
8512 return true;
8513 }
8514 if self.state.claim_mode.is_some() {
8515 self.cancel_claim_mode();
8516 return true;
8517 }
8518 if self.state.relocate_mode.is_some() {
8519 self.cancel_relocate_mode();
8520 return true;
8521 }
8522 if self.state.show_plant_menu {
8523 self.close_plant_menu();
8524 return true;
8525 }
8526 if self.state.show_farm_access {
8527 self.close_farm_access_panel();
8528 return true;
8529 }
8530 if self.state.sell_plot_confirm.is_some() {
8531 self.state.sell_plot_confirm = None;
8532 self.state.sell_plot_armed_at = None;
8533 self.state.push_log("Sell cancelled");
8534 return true;
8535 }
8536 if self.state.show_move_picker {
8537 self.close_move_picker();
8538 return true;
8539 }
8540 if self.state.show_rotation_editor {
8541 match self.state.rotation_editor.mode {
8542 RotationEditorMode::List => {
8543 self.state.show_rotation_editor = false;
8544 self.state.rotation_editor.reset();
8545 }
8546 RotationEditorMode::EditLabel => {
8547 self.state.rotation_editor.label_buffer.clear();
8548 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
8549 }
8550 RotationEditorMode::PickAbility => {
8551 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
8552 }
8553 RotationEditorMode::EditSequence => {
8554 self.state.rotation_editor.draft = None;
8555 self.state.rotation_editor.mode = RotationEditorMode::List;
8556 }
8557 }
8558 return true;
8559 }
8560 if self.state.show_inventory_menu {
8561 self.close_inventory_menu();
8562 return true;
8563 }
8564 if self.state.show_craft_menu {
8565 self.close_craft_menu();
8566 return true;
8567 }
8568 if self.state.show_plot_build_menu {
8569 self.close_plot_build_menu();
8570 return true;
8571 }
8572 if self.state.show_keychain_menu {
8573 self.close_keychain_menu();
8574 return true;
8575 }
8576 if self.state.show_quest_offer {
8577 self.quest_offer_decline();
8578 return true;
8579 }
8580 if self.state.show_shop_menu {
8581 return false;
8583 }
8584 if self.state.bank_panel.is_some() {
8585 return false;
8586 }
8587 if self.state.storage_panel.is_some() {
8588 return false;
8589 }
8590 if self.state.market_panel.is_some() {
8591 return false;
8592 }
8593 if self.state.show_npc_chat {
8594 return false;
8596 }
8597 if self.state.show_npc_verb_menu {
8598 self.state.show_npc_verb_menu = false;
8599 self.state.npc_verb_target = None;
8600 self.state.npc_verb_notice = None;
8601 return true;
8602 }
8603 if self.state.show_quest_menu {
8604 if self.state.quest_withdraw_confirm {
8605 self.state.quest_withdraw_confirm = false;
8606 } else {
8607 self.state.show_quest_menu = false;
8608 }
8609 return true;
8610 }
8611 if self.state.worker_route_editor.is_some() {
8612 if self.re_at_root_sheet() {
8614 let reopen = self.state.attending_worker_instance_id.clone();
8615 self.close_worker_route_editor();
8616 if let Some(id) = reopen {
8617 if let Some(idx) = self
8618 .state
8619 .hired_workers
8620 .iter()
8621 .position(|w| w.instance_id == id)
8622 {
8623 self.state.workers_menu_index = idx;
8624 self.state.show_workers_menu = true;
8625 }
8626 }
8627 } else {
8628 self.re_sheet_back();
8629 }
8630 return true;
8631 }
8632 if self.state.show_worker_give_picker {
8633 self.close_worker_give_picker();
8634 return true;
8635 }
8636 if self.state.show_worker_give_target_picker {
8637 self.close_worker_give_target_picker();
8638 return true;
8639 }
8640 if self.state.show_worker_take_picker {
8641 self.close_worker_take_picker();
8642 return true;
8643 }
8644 if self.state.show_worker_teach_picker {
8645 self.close_worker_teach_picker();
8646 return true;
8647 }
8648 if self.state.show_workers_menu {
8649 self.close_workers_menu_ui();
8650 return true;
8651 }
8652 if self.state.show_loadout_menu {
8653 self.state.show_loadout_menu = false;
8654 return true;
8655 }
8656 if self.state.show_stats {
8657 self.state.show_stats = false;
8658 return true;
8659 }
8660 if self.state.show_equip_menu {
8661 self.state.show_equip_menu = false;
8662 return true;
8663 }
8664 false
8665 }
8666
8667 pub fn toggle_stats(&mut self) {
8668 self.state.show_stats = !self.state.show_stats;
8669 if self.state.show_stats {
8670 self.state.character_sheet_tab = CharacterSheetTab::Character;
8671 self.state.show_craft_menu = false;
8672 self.state.show_shop_menu = false;
8673 self.state.shop_catalog = None;
8674 self.state.show_inventory_menu = false;
8675 self.state.show_equip_menu = false;
8676 }
8677 }
8678
8679 pub fn toggle_equip_menu(&mut self) {
8680 self.state.show_equip_menu = !self.state.show_equip_menu;
8681 if self.state.show_equip_menu {
8682 self.state.show_stats = false;
8683 self.state.show_craft_menu = false;
8684 self.state.show_shop_menu = false;
8685 self.state.shop_catalog = None;
8686 self.state.show_inventory_menu = false;
8687 self.state.show_loadout_menu = false;
8688 }
8689 }
8690
8691 pub fn cycle_character_sheet_tab(&mut self) {
8692 if self.state.show_stats {
8693 self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
8694 }
8695 }
8696
8697 pub fn set_ledger_period_digit(&mut self, c: char) {
8698 if self.state.show_stats {
8699 if let Some(p) = LedgerPeriod::from_digit(c) {
8700 self.state.ledger_period = p;
8701 self.state.character_sheet_tab = CharacterSheetTab::Ledger;
8702 }
8703 }
8704 }
8705
8706 pub fn cycle_ledger_period(&mut self) {
8707 if self.state.show_stats && self.state.character_sheet_tab == CharacterSheetTab::Ledger {
8708 self.state.ledger_period = self.state.ledger_period.cycle();
8709 }
8710 }
8711
8712 pub fn open_inventory_menu(&mut self) {
8713 self.state.show_inventory_menu = true;
8714 self.state.show_craft_menu = false;
8715 self.state.show_shop_menu = false;
8716 self.state.shop_catalog = None;
8717 self.state.show_stats = false;
8718 self.state.show_move_picker = false;
8719 self.state.move_picker = None;
8720 self.state.show_destroy_picker = false;
8721 self.state.destroy_confirm_pending = false;
8722 self.state.destroy_picker = None;
8723 self.state.show_rename_prompt = false;
8724 self.state.rename_plot_id = None;
8725 self.state.rename_buffer.clear();
8726 self.state.inventory_filter_focused = false;
8727 self.state.clamp_inventory_indices();
8728 }
8729
8730 pub fn close_inventory_menu(&mut self) {
8731 self.state.show_inventory_menu = false;
8732 self.state.show_move_picker = false;
8733 self.state.move_picker = None;
8734 self.close_grant_picker();
8735 self.state.show_destroy_picker = false;
8736 self.state.destroy_confirm_pending = false;
8737 self.state.destroy_picker = None;
8738 self.state.show_rename_prompt = false;
8739 self.state.rename_plot_id = None;
8740 self.state.rename_buffer.clear();
8741 self.state.inventory_filter_focused = false;
8742 }
8743
8744 pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
8745 let Some(row) = self.state.inventory_selected_row() else {
8746 anyhow::bail!("inventory empty");
8747 };
8748 if GameState::is_property_deed_template(&row.stack.template_id) {
8749 let Some(plot_id) = GameState::deed_plot_id(&row.stack) else {
8750 anyhow::bail!("deed has no plot id");
8751 };
8752 let label = self
8753 .state
8754 .property_plots
8755 .iter()
8756 .find(|p| p.plot_id == plot_id)
8757 .map(|p| {
8758 if p.label.trim().is_empty() {
8759 p.plot_code.clone()
8760 } else {
8761 p.label.clone()
8762 }
8763 })
8764 .unwrap_or_else(|| {
8765 row.stack
8766 .display_name
8767 .clone()
8768 .unwrap_or_else(|| "plot".into())
8769 });
8770 self.state.rename_buffer = label;
8771 self.state.rename_plot_id = Some(plot_id);
8772 self.state.highlighted_plot_id = Some(plot_id);
8773 self.state.show_rename_prompt = true;
8774 self.state.show_worker_rename = false;
8775 self.state.show_move_picker = false;
8776 self.state.show_destroy_picker = false;
8777 self.state.destroy_confirm_pending = false;
8778 return Ok(());
8779 }
8780 if !self.state.row_is_renameable_container(&row) {
8781 anyhow::bail!("only storage containers or deeds can be renamed");
8782 }
8783 let current = row
8784 .stack
8785 .display_name
8786 .clone()
8787 .unwrap_or_else(|| row.stack.template_id.clone());
8788 self.state.rename_buffer = current;
8789 self.state.rename_plot_id = None;
8790 self.state.show_rename_prompt = true;
8791 self.state.show_worker_rename = false;
8792 self.state.show_move_picker = false;
8793 self.state.show_destroy_picker = false;
8794 self.state.destroy_confirm_pending = false;
8795 Ok(())
8796 }
8797
8798 pub fn open_plot_rename_under_player(&mut self) -> anyhow::Result<()> {
8800 let Some(plot) = self.state.my_plot_under_player().cloned() else {
8801 anyhow::bail!("stand on your plot to rename it");
8802 };
8803 let label = if plot.label.trim().is_empty() {
8804 plot.plot_code.clone()
8805 } else {
8806 plot.label.clone()
8807 };
8808 self.state.rename_buffer = label;
8809 self.state.rename_plot_id = Some(plot.plot_id);
8810 self.state.highlighted_plot_id = Some(plot.plot_id);
8811 self.state.show_rename_prompt = true;
8812 self.state.show_worker_rename = false;
8813 Ok(())
8814 }
8815
8816 pub fn cancel_rename_prompt(&mut self) {
8817 self.state.show_rename_prompt = false;
8818 self.state.rename_plot_id = None;
8819 self.state.rename_buffer.clear();
8820 }
8821
8822 pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
8823 let name = self.state.rename_buffer.trim().to_string();
8824 if name.is_empty() {
8825 anyhow::bail!("name cannot be empty");
8826 }
8827 if let Some(plot_id) = self.state.rename_plot_id {
8828 if name.chars().count() > 48 {
8829 anyhow::bail!("label must be 1–48 characters");
8830 }
8831 self.seq += 1;
8832 self.session
8833 .submit_intent(Intent::RenamePropertyPlot {
8834 entity_id: self.state.entity_id,
8835 plot_id,
8836 label: name,
8837 seq: self.seq,
8838 })
8839 .await?;
8840 self.state.intents_sent += 1;
8841 self.state.show_rename_prompt = false;
8842 self.state.rename_plot_id = None;
8843 self.state.rename_buffer.clear();
8844 return Ok(());
8845 }
8846 if name.chars().count() > 32 {
8847 anyhow::bail!("name must be 1–32 characters");
8848 }
8849 let Some(row) = self.state.inventory_selected_row() else {
8850 anyhow::bail!("inventory empty");
8851 };
8852 let Some(instance_id) = row.stack.item_instance_id else {
8853 anyhow::bail!("item has no instance id");
8854 };
8855 self.seq += 1;
8856 self.session
8857 .submit_intent(Intent::RenameContainer {
8858 entity_id: self.state.entity_id,
8859 item_instance_id: instance_id,
8860 location: row.from.clone(),
8861 name,
8862 seq: self.seq,
8863 })
8864 .await?;
8865 self.state.intents_sent += 1;
8866 self.state.show_rename_prompt = false;
8867 self.state.rename_buffer.clear();
8868 Ok(())
8869 }
8870
8871 pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
8872 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
8873 anyhow::bail!("no worker selected");
8874 };
8875 self.state.rename_buffer = worker.label.clone();
8876 self.state.show_worker_rename = true;
8877 self.state.show_rename_prompt = false;
8878 Ok(())
8879 }
8880
8881 pub fn cancel_worker_rename(&mut self) {
8882 self.state.show_worker_rename = false;
8883 self.state.rename_buffer.clear();
8884 }
8885
8886 pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
8887 let name = self.state.rename_buffer.trim().to_string();
8888 if name.is_empty() {
8889 anyhow::bail!("name cannot be empty");
8890 }
8891 if name.chars().count() > 32 {
8892 anyhow::bail!("name must be 1–32 characters");
8893 }
8894 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
8895 anyhow::bail!("no worker selected");
8896 };
8897 let worker_instance_id = worker.instance_id.clone();
8898 self.seq += 1;
8899 self.session
8900 .submit_intent(Intent::RenameHiredWorker {
8901 entity_id: self.state.entity_id,
8902 worker_instance_id: worker_instance_id.clone(),
8903 name: name.clone(),
8904 seq: self.seq,
8905 })
8906 .await?;
8907 self.state.intents_sent += 1;
8908 if let Some(w) = self
8909 .state
8910 .hired_workers
8911 .iter_mut()
8912 .find(|w| w.instance_id == worker_instance_id)
8913 {
8914 w.label = name.clone();
8915 }
8916 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8917 if ed.worker_instance_id == worker_instance_id {
8918 ed.worker_label = name.clone();
8919 }
8920 }
8921 self.state.show_worker_rename = false;
8922 self.state.rename_buffer.clear();
8923 self.state.push_log(format!("Renamed worker to \"{name}\""));
8924 Ok(())
8925 }
8926
8927 pub fn toggle_inventory_menu(&mut self) {
8928 if self.state.show_inventory_menu {
8929 self.close_inventory_menu();
8930 } else {
8931 self.open_inventory_menu();
8932 }
8933 }
8934
8935 pub fn inventory_menu_move(&mut self, delta: i32) {
8937 if self.state.show_grant_picker {
8938 let Some(picker) = self.state.grant_picker.as_ref() else {
8939 return;
8940 };
8941 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8942 let filter = picker.filter.clone();
8943 let n = labels.len();
8944 if n == 0 {
8945 return;
8946 }
8947 self.state.grant_picker_index =
8948 step_filtered_index(self.state.grant_picker_index, delta, n, |i| {
8949 list_label_matches(&labels[i], &filter)
8950 });
8951 return;
8952 }
8953 if self.state.show_move_picker {
8954 let Some(picker) = self.state.move_picker.as_ref() else {
8955 return;
8956 };
8957 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8958 let filter = picker.filter.clone();
8959 let n = labels.len();
8960 if n == 0 {
8961 return;
8962 }
8963 self.state.move_picker_index =
8964 step_filtered_index(self.state.move_picker_index, delta, n, |i| {
8965 list_label_matches(&labels[i], &filter)
8966 });
8967 self.state.clamp_move_picker_quantity();
8968 return;
8969 }
8970 let n = self.state.inventory_selectable_rows().len();
8971 if n == 0 {
8972 return;
8973 }
8974 let idx = self.state.inventory_menu_index as i32;
8975 self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8976 }
8977
8978 pub fn inventory_menu_page(&mut self, pages: i32) {
8980 if self.state.show_grant_picker {
8981 let Some(picker) = self.state.grant_picker.as_ref() else {
8982 return;
8983 };
8984 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8985 let filter = picker.filter.clone();
8986 let n = labels.len();
8987 self.state.grant_picker_index =
8988 page_filtered_index(self.state.grant_picker_index, pages, n, |i| {
8989 list_label_matches(&labels[i], &filter)
8990 });
8991 return;
8992 }
8993 if self.state.show_move_picker {
8994 let Some(picker) = self.state.move_picker.as_ref() else {
8995 return;
8996 };
8997 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8998 let filter = picker.filter.clone();
8999 let n = labels.len();
9000 self.state.move_picker_index =
9001 page_filtered_index(self.state.move_picker_index, pages, n, |i| {
9002 list_label_matches(&labels[i], &filter)
9003 });
9004 self.state.clamp_move_picker_quantity();
9005 return;
9006 }
9007 let n = self.state.inventory_selectable_rows().len();
9008 self.state.inventory_menu_index =
9009 page_list_index(self.state.inventory_menu_index, pages, n);
9010 }
9011
9012 pub fn cycle_inventory_tab(&mut self, forward: bool) {
9013 if self.state.show_move_picker
9014 || self.state.show_grant_picker
9015 || self.state.show_destroy_picker
9016 || self.state.show_rename_prompt
9017 || self.state.inventory_filter_focused
9018 {
9019 return;
9020 }
9021 self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
9022 self.state.inventory_menu_index = 0;
9023 self.state.clamp_inventory_indices();
9024 }
9025
9026 pub fn focus_inventory_filter(&mut self) {
9027 if self.state.show_grant_picker {
9028 if let Some(p) = self.state.grant_picker.as_mut() {
9029 p.filter_focused = true;
9030 }
9031 return;
9032 }
9033 if self.state.show_move_picker {
9034 if let Some(p) = self.state.move_picker.as_mut() {
9035 p.filter_focused = true;
9036 }
9037 return;
9038 }
9039 self.state.inventory_filter_focused = true;
9040 }
9041
9042 pub fn set_inventory_filter(&mut self, filter: String) {
9043 self.state.inventory_filter = filter;
9044 self.state.inventory_menu_index = 0;
9045 self.state.clamp_inventory_indices();
9046 }
9047
9048 pub fn append_inventory_filter_char(&mut self, ch: char) {
9049 if !is_list_filter_char(ch) {
9050 return;
9051 }
9052 if self.state.show_grant_picker {
9053 if let Some(p) = self.state.grant_picker.as_mut() {
9054 if p.filter_focused {
9055 p.filter.push(ch);
9056 self.state.grant_picker_index = 0;
9057 }
9058 }
9059 return;
9060 }
9061 if self.state.show_move_picker {
9062 if let Some(p) = self.state.move_picker.as_mut() {
9063 if p.filter_focused {
9064 p.filter.push(ch);
9065 self.state.move_picker_index = 0;
9066 self.state.clamp_move_picker_quantity();
9067 }
9068 }
9069 return;
9070 }
9071 if !self.state.inventory_filter_focused {
9072 return;
9073 }
9074 self.state.inventory_filter.push(ch);
9075 self.state.inventory_menu_index = 0;
9076 self.state.clamp_inventory_indices();
9077 }
9078
9079 pub fn inventory_filter_backspace(&mut self) {
9080 if self.state.show_grant_picker {
9081 if let Some(p) = self.state.grant_picker.as_mut() {
9082 if p.filter_focused {
9083 p.filter.pop();
9084 self.state.grant_picker_index = 0;
9085 }
9086 }
9087 return;
9088 }
9089 if self.state.show_move_picker {
9090 if let Some(p) = self.state.move_picker.as_mut() {
9091 if p.filter_focused {
9092 p.filter.pop();
9093 self.state.move_picker_index = 0;
9094 self.state.clamp_move_picker_quantity();
9095 }
9096 }
9097 return;
9098 }
9099 if !self.state.inventory_filter_focused {
9100 return;
9101 }
9102 self.state.inventory_filter.pop();
9103 self.state.inventory_menu_index = 0;
9104 self.state.clamp_inventory_indices();
9105 }
9106
9107 pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
9109 if self.state.show_grant_picker {
9110 if let Some(p) = self.state.grant_picker.as_mut() {
9111 if p.filter_focused {
9112 if !p.filter.is_empty() {
9113 p.filter.clear();
9114 self.state.grant_picker_index = 0;
9115 } else {
9116 p.filter_focused = false;
9117 }
9118 return true;
9119 }
9120 if !p.filter.is_empty() {
9121 p.filter.clear();
9122 self.state.grant_picker_index = 0;
9123 return true;
9124 }
9125 }
9126 return false;
9127 }
9128 if self.state.show_move_picker {
9129 if let Some(p) = self.state.move_picker.as_mut() {
9130 if p.filter_focused {
9131 if !p.filter.is_empty() {
9132 p.filter.clear();
9133 self.state.move_picker_index = 0;
9134 self.state.clamp_move_picker_quantity();
9135 } else {
9136 p.filter_focused = false;
9137 }
9138 return true;
9139 }
9140 if !p.filter.is_empty() {
9141 p.filter.clear();
9142 self.state.move_picker_index = 0;
9143 self.state.clamp_move_picker_quantity();
9144 return true;
9145 }
9146 }
9147 return false;
9148 }
9149 if self.state.inventory_filter_focused {
9150 if !self.state.inventory_filter.is_empty() {
9151 self.state.inventory_filter.clear();
9152 self.state.inventory_menu_index = 0;
9153 self.state.clamp_inventory_indices();
9154 } else {
9155 self.state.inventory_filter_focused = false;
9156 }
9157 return true;
9158 }
9159 if !self.state.inventory_filter.is_empty() {
9160 self.state.inventory_filter.clear();
9161 self.state.inventory_menu_index = 0;
9162 self.state.clamp_inventory_indices();
9163 return true;
9164 }
9165 false
9166 }
9167
9168 pub fn craft_menu_page(&mut self, pages: i32) {
9169 let n = self.state.craft_filtered_indices().len();
9170 self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
9171 self.state.clamp_craft_batch_quantity();
9172 }
9173
9174 pub fn shop_menu_page(&mut self, pages: i32) {
9175 let n = self.state.shop_list_len();
9176 self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
9177 self.state.clamp_shop_quantity();
9178 }
9179
9180 pub fn workers_menu_page(&mut self, pages: i32) {
9181 let n = self.state.hired_workers.len();
9182 self.state.workers_menu_index = page_list_index(self.state.workers_menu_index, pages, n);
9183 }
9184
9185 pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
9190 if self.state.show_destroy_picker {
9191 if self.state.destroy_confirm_pending {
9192 return self.confirm_destroy_item().await;
9193 }
9194 return self.request_destroy_confirm();
9195 }
9196 if self.state.show_grant_picker {
9197 return self.confirm_grant_picker().await;
9198 }
9199 if self.state.show_move_picker {
9200 return self.confirm_move_picker().await;
9201 }
9202 let Some(row) = self.state.inventory_selected_row() else {
9203 anyhow::bail!("inventory empty");
9204 };
9205 if row.is_equip_shell {
9206 let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
9207 anyhow::bail!("not a worn item");
9208 };
9209 return self.equip_worn(slot, None).await;
9210 }
9211 if row.is_chest_shell {
9212 return self.open_chest_pickup_picker();
9213 }
9214 let template_id = row.stack.template_id.clone();
9215 let instance_id = row.stack.item_instance_id;
9216 let category = self.state.inventory_item_category(&template_id);
9217 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
9218
9219 if category == Some("weapon") {
9220 return self.equip_mainhand(Some(template_id)).await;
9221 }
9222 if category == Some("lodging") && on_person {
9223 if let Some(inst) = instance_id {
9224 return self.place_container(inst).await;
9225 }
9226 }
9227 if on_person {
9229 if let Some(inst) = instance_id {
9230 if row.stack.world_placeable == Some(true) {
9231 return self.place_container(inst).await;
9232 }
9233 }
9234 }
9235 if (category == Some("container") || category == Some("armor")) && on_person {
9236 if let Some(inst) = instance_id {
9237 let world_placeable =
9238 row.stack.world_placeable == Some(true) || template_id.contains("chest");
9239 if world_placeable {
9240 return self.place_container(inst).await;
9241 }
9242 if let Some(slot) = guess_body_slot(&template_id) {
9246 return self.equip_worn(slot, Some(inst)).await;
9247 }
9248 }
9249 }
9250 self.open_move_picker()
9254 }
9255
9256 pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
9258 let Some(row) = self.state.inventory_selected_row() else {
9259 anyhow::bail!("inventory empty");
9260 };
9261 if row.from != flatland_protocol::InventoryLocation::Root {
9262 anyhow::bail!("select a consumable on your person");
9263 }
9264 if GameState::stack_is_item_grant(&row.stack) {
9265 return self.open_grant_target_picker();
9266 }
9267 if GameState::is_property_deed_template(&row.stack.template_id) {
9268 return self.open_move_picker();
9269 }
9270 let category = self.state.inventory_item_category(&row.stack.template_id);
9271 if category != Some("consumable") && !GameState::stack_is_serving(&row.stack) {
9272 anyhow::bail!("selected item is not usable");
9273 }
9274 self.use_item(&row.stack.template_id).await
9275 }
9276
9277 pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
9279 let Some(row) = self.state.inventory_selected_row() else {
9280 anyhow::bail!("inventory empty");
9281 };
9282 if row.from != flatland_protocol::InventoryLocation::Root {
9283 anyhow::bail!("select a grant item on your person");
9284 }
9285 if !GameState::stack_is_item_grant(&row.stack) {
9286 anyhow::bail!("selected item does not grant onto gear");
9287 }
9288 let Some(grant_instance_id) = row.stack.item_instance_id else {
9289 anyhow::bail!("grant has no instance id");
9290 };
9291 let effect_id = GameState::grant_effect_id(&row.stack)
9292 .unwrap_or("?")
9293 .to_string();
9294 let mode = GameState::grant_mode(&row.stack).to_string();
9295 let options = self.state.grant_target_options(&row.stack);
9296 if options.is_empty() {
9297 anyhow::bail!("no valid gear to apply {effect_id} to");
9298 }
9299 let grant_label = row
9300 .stack
9301 .display_name
9302 .clone()
9303 .unwrap_or_else(|| row.stack.template_id.clone());
9304 self.state.show_grant_picker = true;
9305 self.state.grant_picker_index = 0;
9306 self.state.grant_picker = Some(GrantTargetPicker {
9307 grant_instance_id,
9308 grant_label,
9309 effect_id,
9310 mode,
9311 options,
9312 filter: String::new(),
9313 filter_focused: false,
9314 });
9315 Ok(())
9316 }
9317
9318 pub fn close_grant_picker(&mut self) {
9319 self.state.show_grant_picker = false;
9320 self.state.grant_picker = None;
9321 self.state.grant_picker_index = 0;
9322 }
9323
9324 pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
9325 let Some(picker) = self.state.grant_picker.clone() else {
9326 self.close_grant_picker();
9327 return Ok(());
9328 };
9329 let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
9330 self.close_grant_picker();
9331 return Ok(());
9332 };
9333 self.close_grant_picker();
9334 self.use_grant(picker.grant_instance_id, opt.target_instance_id)
9335 .await?;
9336 self.state
9337 .push_log(format!("Applying {} onto {}…", picker.effect_id, opt.label));
9338 Ok(())
9339 }
9340
9341 pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
9345 let Some(row) = self.state.inventory_selected_row() else {
9346 anyhow::bail!("inventory empty");
9347 };
9348 if row.is_equip_shell {
9349 anyhow::bail!("this is a worn bag — press Enter to unequip it");
9350 }
9351 if row.is_chest_shell {
9352 return self.open_chest_pickup_picker();
9353 }
9354 let Some(instance_id) = row.stack.item_instance_id else {
9355 anyhow::bail!("item has no instance id");
9356 };
9357 let mut options = self.state.move_destinations_for(
9358 &row.from,
9359 row.from_parent_instance_id,
9360 row.stack.item_instance_id,
9361 &row.stack.template_id,
9362 );
9363 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
9364 let category = self.state.inventory_item_category(&row.stack.template_id);
9365 if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
9366 if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
9367 options.insert(
9368 0,
9369 MoveOption {
9370 label: "Sell plot to crown…".into(),
9371 kind: MoveOptionKind::SellPlotToCrown { plot_id },
9372 },
9373 );
9374 }
9375 }
9376 if on_person && category == Some("consumable") {
9377 if GameState::stack_is_item_grant(&row.stack) {
9378 options.insert(
9379 0,
9380 MoveOption {
9381 label: "Apply onto gear…".into(),
9382 kind: MoveOptionKind::GrantApply,
9383 },
9384 );
9385 } else {
9386 let study = GameState::stack_is_blueprint_scroll(&row.stack);
9387 options.insert(
9388 0,
9389 MoveOption {
9390 label: if study {
9391 "Study".into()
9392 } else {
9393 "Use (eat / drink)".into()
9394 },
9395 kind: MoveOptionKind::Use,
9396 },
9397 );
9398 }
9399 } else if on_person && GameState::stack_is_serving(&row.stack) {
9400 let label = if GameState::stack_is_food_serving(&row.stack) {
9401 "Use (eat)"
9402 } else {
9403 "Use (fill / drink)"
9404 };
9405 options.insert(
9406 0,
9407 MoveOption {
9408 label: label.into(),
9409 kind: MoveOptionKind::Use,
9410 },
9411 );
9412 }
9413 let item_label = row
9414 .stack
9415 .display_name
9416 .clone()
9417 .unwrap_or_else(|| row.stack.template_id.clone());
9418 let initial_qty = if row.stack.quantity > 1 {
9421 1
9422 } else {
9423 row.stack.quantity
9424 };
9425 self.state.move_picker = Some(MovePicker {
9426 item_instance_id: instance_id,
9427 from: row.from,
9428 item_label,
9429 template_id: row.stack.template_id.clone(),
9430 stack_quantity: row.stack.quantity,
9431 quantity: initial_qty.max(1),
9432 options,
9433 filter: String::new(),
9434 filter_focused: false,
9435 });
9436 self.state.move_picker_index = 0;
9437 self.state.show_move_picker = true;
9438 self.state.show_destroy_picker = false;
9439 self.state.destroy_confirm_pending = false;
9440 self.state.destroy_picker = None;
9441 self.state.clamp_move_picker_quantity();
9442 Ok(())
9443 }
9444
9445 pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
9447 let Some(row) = self.state.inventory_selected_row() else {
9448 anyhow::bail!("inventory empty");
9449 };
9450 if !row.is_chest_shell {
9451 anyhow::bail!("not a placed chest");
9452 }
9453 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
9454 anyhow::bail!("not a placed chest");
9455 };
9456 let Some(instance_id) = row.stack.item_instance_id else {
9457 anyhow::bail!("chest has no instance id");
9458 };
9459 let chest = self
9460 .state
9461 .placed_containers
9462 .iter()
9463 .find(|c| c.id == *container_id)
9464 .cloned()
9465 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
9466 let (px, py) = self.state.player_position();
9467 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
9468 anyhow::bail!("too far from {}", chest.display_name);
9469 }
9470 if chest.locked && !chest.accessible {
9471 anyhow::bail!(
9472 "need the matching key for {} before picking it up",
9473 chest.display_name
9474 );
9475 }
9476 let options = self.state.chest_pickup_destinations(container_id);
9477 let item_label = row
9478 .stack
9479 .display_name
9480 .clone()
9481 .unwrap_or_else(|| row.stack.template_id.clone());
9482 self.state.move_picker = Some(MovePicker {
9483 item_instance_id: instance_id,
9484 from: row.from.clone(),
9485 item_label,
9486 template_id: row.stack.template_id.clone(),
9487 stack_quantity: 1,
9488 quantity: 1,
9489 options,
9490 filter: String::new(),
9491 filter_focused: false,
9492 });
9493 self.state.move_picker_index = 0;
9494 self.state.show_move_picker = true;
9495 self.state.show_destroy_picker = false;
9496 self.state.destroy_confirm_pending = false;
9497 self.state.destroy_picker = None;
9498 Ok(())
9499 }
9500
9501 pub fn close_move_picker(&mut self) {
9502 self.state.show_move_picker = false;
9503 self.state.move_picker = None;
9504 }
9505
9506 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
9507 self.state.move_picker_adjust_quantity(delta);
9508 }
9509
9510 pub fn move_picker_set_quantity_max(&mut self) {
9511 self.state.move_picker_set_quantity_max();
9512 }
9513
9514 pub fn move_picker_set_quantity_min(&mut self) {
9515 self.state.move_picker_set_quantity_min();
9516 }
9517
9518 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
9519 self.state.destroy_picker_adjust_quantity(delta);
9520 }
9521
9522 pub fn destroy_picker_set_quantity_max(&mut self) {
9523 self.state.destroy_picker_set_quantity_max();
9524 }
9525
9526 pub fn destroy_picker_set_quantity_min(&mut self) {
9527 self.state.destroy_picker_set_quantity_min();
9528 }
9529
9530 async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
9531 let Some(picker) = self.state.move_picker.clone() else {
9532 self.close_move_picker();
9533 return Ok(());
9534 };
9535 let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
9536 self.close_move_picker();
9537 return Ok(());
9538 };
9539 match option.kind {
9540 MoveOptionKind::Cancel => {
9541 self.close_move_picker();
9542 }
9543 MoveOptionKind::Use => {
9544 self.close_move_picker();
9545 self.use_item(&picker.template_id).await?;
9546 }
9547 MoveOptionKind::GrantApply => {
9548 self.close_move_picker();
9549 self.open_grant_target_picker()?;
9550 }
9551 MoveOptionKind::SellPlotToCrown { plot_id } => {
9552 self.close_move_picker();
9553 self.confirm_sell_plot_to_crown(plot_id).await?;
9554 }
9555 MoveOptionKind::RelocatePlaced { container_id } => {
9556 self.close_move_picker();
9557 self.state.show_inventory_menu = false;
9558 self.begin_relocate_container(&container_id)?;
9559 }
9560 MoveOptionKind::Drop => {
9561 self.close_move_picker();
9562 if self
9563 .state
9564 .hand_equipped_instance_ids()
9565 .contains(&picker.item_instance_id)
9566 {
9567 anyhow::bail!("unequip that item first");
9568 }
9569 if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
9570 if self.state.deed_bound(&stack) {
9571 anyhow::bail!(
9572 "cannot drop a property deed — store it or trade it to another player"
9573 );
9574 }
9575 if self.state.key_drop_blocked(&stack) {
9576 anyhow::bail!("cannot drop the key while its chest is locked");
9577 }
9578 }
9579 self.drop_item(picker.item_instance_id, picker.from).await?;
9580 self.state
9581 .push_log(format!("Dropped {}", picker.item_label));
9582 }
9583 MoveOptionKind::PickupPlaced {
9584 container_id,
9585 nest_location,
9586 nest_parent_instance_id,
9587 } => {
9588 self.close_move_picker();
9589 self.pickup_container(container_id.clone()).await?;
9590 let nest_into_bag = nest_parent_instance_id.is_some()
9591 || !matches!(nest_location, flatland_protocol::InventoryLocation::Root);
9592 if nest_into_bag {
9593 self.move_item(
9594 picker.item_instance_id,
9595 flatland_protocol::InventoryLocation::Root,
9596 nest_location,
9597 nest_parent_instance_id,
9598 None,
9599 )
9600 .await?;
9601 self.state
9602 .push_log(format!("Picked up {} into bag", picker.item_label));
9603 } else {
9604 self.state
9605 .push_log(format!("Picked up {}", picker.item_label));
9606 }
9607 }
9608 MoveOptionKind::Move {
9609 location,
9610 parent_instance_id,
9611 } => {
9612 self.close_move_picker();
9613 let qty = if picker.quantity >= picker.stack_quantity {
9614 None
9615 } else {
9616 Some(picker.quantity)
9617 };
9618 self.move_item(
9619 picker.item_instance_id,
9620 picker.from,
9621 location,
9622 parent_instance_id,
9623 qty,
9624 )
9625 .await?;
9626 let moved = qty.unwrap_or(picker.stack_quantity);
9627 if moved >= picker.stack_quantity {
9628 self.state.push_log(format!("Moved {}", picker.item_label));
9629 } else {
9630 self.state.push_log(format!(
9631 "Moved {} ×{} of {}",
9632 picker.item_label, moved, picker.stack_quantity
9633 ));
9634 }
9635 }
9636 }
9637 Ok(())
9638 }
9639
9640 pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
9644 let Some(row) = self.state.inventory_selected_row() else {
9645 anyhow::bail!("inventory empty");
9646 };
9647 if row.is_equip_shell {
9648 anyhow::bail!("unequip the bag first (Enter), then drop from your person");
9649 }
9650 if row.is_chest_shell {
9651 anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
9652 }
9653 let Some(inst) = row.stack.item_instance_id else {
9654 anyhow::bail!("item has no instance id");
9655 };
9656 if self.state.hand_equipped_instance_ids().contains(&inst) {
9657 anyhow::bail!("unequip that item first");
9658 }
9659 if self.state.deed_bound(&row.stack) {
9660 anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
9661 }
9662 if self.state.key_drop_blocked(&row.stack) {
9663 anyhow::bail!("cannot drop the key while its chest is locked");
9664 }
9665 let label = row
9666 .stack
9667 .display_name
9668 .clone()
9669 .unwrap_or_else(|| row.stack.template_id.clone());
9670 let placeable = row.stack.world_placeable == Some(true)
9671 || row.from == flatland_protocol::InventoryLocation::Root
9672 && matches!(
9673 self.state.inventory_item_category(&row.stack.template_id).as_deref(),
9674 Some("lodging")
9675 );
9676 if placeable && row.from == flatland_protocol::InventoryLocation::Root {
9677 self.place_container(inst).await?;
9678 self.state.push_log(format!("Placed {label}"));
9679 return Ok(());
9680 }
9681 self.drop_item(inst, row.from).await?;
9682 self.state.push_log(format!("Dropped {label}"));
9683 Ok(())
9684 }
9685
9686 pub async fn drop_item(
9687 &mut self,
9688 item_instance_id: uuid::Uuid,
9689 from: flatland_protocol::InventoryLocation,
9690 ) -> anyhow::Result<()> {
9691 self.seq += 1;
9692 self.session
9693 .submit_intent(Intent::DropItem {
9694 entity_id: self.state.entity_id,
9695 item_instance_id,
9696 from,
9697 seq: self.seq,
9698 })
9699 .await?;
9700 self.state.intents_sent += 1;
9701 Ok(())
9702 }
9703
9704 pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
9706 let Some(row) = self.state.inventory_selected_row() else {
9707 anyhow::bail!("inventory empty");
9708 };
9709 if row.is_equip_shell {
9710 anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
9711 }
9712 if row.is_chest_shell {
9713 anyhow::bail!("can't destroy a placed chest from the inventory list");
9714 }
9715 let Some(instance_id) = row.stack.item_instance_id else {
9716 anyhow::bail!("item has no instance id");
9717 };
9718 if self
9719 .state
9720 .hand_equipped_instance_ids()
9721 .contains(&instance_id)
9722 {
9723 anyhow::bail!("unequip that item first");
9724 }
9725 if self.state.deed_bound(&row.stack) {
9726 anyhow::bail!(
9727 "cannot destroy a property deed — store it or trade it to another player"
9728 );
9729 }
9730 if self.state.key_drop_blocked(&row.stack) {
9731 anyhow::bail!("cannot destroy the key while its chest is locked");
9732 }
9733 let item_label = row
9734 .stack
9735 .display_name
9736 .clone()
9737 .unwrap_or_else(|| row.stack.template_id.clone());
9738 self.state.destroy_picker = Some(DestroyPicker {
9739 item_instance_id: instance_id,
9740 from: row.from,
9741 item_label,
9742 stack_quantity: row.stack.quantity,
9743 quantity: row.stack.quantity,
9744 });
9745 self.state.destroy_confirm_pending = false;
9746 self.state.show_destroy_picker = true;
9747 self.state.show_move_picker = false;
9748 self.state.move_picker = None;
9749 Ok(())
9750 }
9751
9752 pub fn close_destroy_picker(&mut self) {
9753 self.state.show_destroy_picker = false;
9754 self.state.destroy_confirm_pending = false;
9755 self.state.destroy_picker = None;
9756 }
9757
9758 pub fn cancel_destroy_confirm(&mut self) {
9759 self.state.destroy_confirm_pending = false;
9760 }
9761
9762 pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
9763 if self.state.destroy_picker.is_none() {
9764 self.close_destroy_picker();
9765 return Ok(());
9766 }
9767 self.state.destroy_confirm_pending = true;
9768 Ok(())
9769 }
9770
9771 pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
9772 let Some(picker) = self.state.destroy_picker.clone() else {
9773 self.close_destroy_picker();
9774 return Ok(());
9775 };
9776 let qty = if picker.quantity >= picker.stack_quantity {
9777 None
9778 } else {
9779 Some(picker.quantity)
9780 };
9781 self.destroy_item(picker.item_instance_id, picker.from, qty)
9782 .await?;
9783 let destroyed = qty.unwrap_or(picker.stack_quantity);
9784 if destroyed >= picker.stack_quantity {
9785 self.state
9786 .push_log(format!("Destroyed {}", picker.item_label));
9787 } else {
9788 self.state.push_log(format!(
9789 "Destroyed {} ×{} of {}",
9790 picker.item_label, destroyed, picker.stack_quantity
9791 ));
9792 }
9793 self.close_destroy_picker();
9794 Ok(())
9795 }
9796
9797 pub async fn destroy_item(
9798 &mut self,
9799 item_instance_id: uuid::Uuid,
9800 from: flatland_protocol::InventoryLocation,
9801 quantity: Option<u32>,
9802 ) -> anyhow::Result<()> {
9803 self.seq += 1;
9804 self.session
9805 .submit_intent(Intent::DestroyItem {
9806 entity_id: self.state.entity_id,
9807 item_instance_id,
9808 from,
9809 quantity,
9810 seq: self.seq,
9811 })
9812 .await?;
9813 self.state.intents_sent += 1;
9814 Ok(())
9815 }
9816
9817 pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
9819 if let Some(row) = self.state.inventory_selected_row() {
9820 if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
9821 return self.toggle_placed_chest_lock(container_id).await;
9822 }
9823 }
9824 self.toggle_nearby_chest_lock().await
9825 }
9826
9827 pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
9828 let chest = self
9829 .state
9830 .placed_containers
9831 .iter()
9832 .find(|c| c.id == container_id)
9833 .cloned()
9834 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
9835 let (px, py) = self.state.player_position();
9836 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
9837 anyhow::bail!("too far from {}", chest.display_name);
9838 }
9839 if !chest.accessible && chest.locked {
9840 anyhow::bail!(
9841 "need the matching key for {} (each crafted chest has its own key)",
9842 chest.display_name
9843 );
9844 }
9845 let lock = !chest.locked;
9846 self.set_container_locked(
9847 flatland_protocol::InventoryLocation::Placed {
9848 container_id: chest.id.clone(),
9849 },
9850 lock,
9851 )
9852 .await?;
9853 self.state.push_log(if lock {
9854 format!("Locked {}", chest.display_name)
9855 } else {
9856 format!("Unlocked {}", chest.display_name)
9857 });
9858 Ok(())
9859 }
9860
9861 pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
9863 let chest = self
9864 .state
9865 .nearest_placed_container(CONTAINER_RANGE_M)
9866 .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
9867 self.toggle_placed_chest_lock(&chest.id).await
9868 }
9869
9870 pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
9871 self.equip_mainhand(None).await
9872 }
9873
9874 pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
9875 if !self.state.is_alive() {
9876 anyhow::bail!("you are dead");
9877 }
9878 self.seq += 1;
9879 self.session
9880 .submit_intent(Intent::EquipOffhand {
9881 entity_id: self.state.entity_id,
9882 template_id,
9883 instance_id: None,
9884 seq: self.seq,
9885 })
9886 .await?;
9887 self.state.intents_sent += 1;
9888 Ok(())
9889 }
9890
9891 pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
9892 self.equip_offhand(None).await
9893 }
9894
9895 pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
9896 let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
9897 for slot in slots {
9898 self.equip_worn(slot, None).await?;
9899 }
9900 Ok(())
9901 }
9902
9903 pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
9904 let (px, py) = self.state.player_position();
9905 let in_range: Vec<_> = self
9906 .state
9907 .placed_containers
9908 .iter()
9909 .filter(|c| self.state.placed_container_in_current_space(c))
9910 .filter(|c| (c.x - px).hypot(c.y - py) <= 2.0)
9911 .collect();
9912 let nearest_free = in_range
9913 .iter()
9914 .copied()
9915 .filter(|c| !self.state.lodging_is_occupied(&c.id))
9916 .min_by(|a, b| {
9917 let da = (a.x - px).hypot(a.y - py);
9918 let db = (b.x - px).hypot(b.y - py);
9919 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
9920 })
9921 .cloned();
9922 if let Some(chest) = nearest_free {
9923 return self.pickup_container(chest.id).await;
9924 }
9925 if in_range
9926 .iter()
9927 .any(|c| self.state.lodging_is_occupied(&c.id))
9928 {
9929 anyhow::bail!("dismiss or reassign workers before picking up lodging");
9930 }
9931 if in_range.is_empty() {
9932 anyhow::bail!("no chest nearby");
9933 }
9934 anyhow::bail!("too far from chest");
9935 }
9936
9937 pub async fn equip_worn(
9938 &mut self,
9939 slot: BodySlot,
9940 instance_id: Option<uuid::Uuid>,
9941 ) -> anyhow::Result<()> {
9942 self.seq += 1;
9943 self.session
9944 .submit_intent(Intent::EquipWorn {
9945 entity_id: self.state.entity_id,
9946 slot,
9947 instance_id,
9948 seq: self.seq,
9949 })
9950 .await?;
9951 self.state.intents_sent += 1;
9952 Ok(())
9953 }
9954
9955 pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
9956 self.seq += 1;
9957 self.session
9958 .submit_intent(Intent::PlaceContainer {
9959 entity_id: self.state.entity_id,
9960 item_instance_id,
9961 seq: self.seq,
9962 })
9963 .await?;
9964 self.state.intents_sent += 1;
9965 Ok(())
9966 }
9967
9968 pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
9969 self.seq += 1;
9970 self.session
9971 .submit_intent(Intent::PickupContainer {
9972 entity_id: self.state.entity_id,
9973 container_id,
9974 seq: self.seq,
9975 })
9976 .await?;
9977 self.state.intents_sent += 1;
9978 Ok(())
9979 }
9980
9981 pub async fn move_item(
9982 &mut self,
9983 item_instance_id: uuid::Uuid,
9984 from: flatland_protocol::InventoryLocation,
9985 to: flatland_protocol::InventoryLocation,
9986 to_parent_instance_id: Option<uuid::Uuid>,
9987 quantity: Option<u32>,
9988 ) -> anyhow::Result<()> {
9989 self.seq += 1;
9990 self.session
9991 .submit_intent(Intent::MoveItem {
9992 entity_id: self.state.entity_id,
9993 item_instance_id,
9994 from,
9995 to,
9996 to_parent_instance_id,
9997 quantity,
9998 seq: self.seq,
9999 })
10000 .await?;
10001 self.state.intents_sent += 1;
10002 Ok(())
10003 }
10004
10005 pub async fn set_container_locked(
10006 &mut self,
10007 location: flatland_protocol::InventoryLocation,
10008 locked: bool,
10009 ) -> anyhow::Result<()> {
10010 self.seq += 1;
10011 self.session
10012 .submit_intent(Intent::SetContainerLocked {
10013 entity_id: self.state.entity_id,
10014 location,
10015 locked,
10016 seq: self.seq,
10017 })
10018 .await?;
10019 self.state.intents_sent += 1;
10020 Ok(())
10021 }
10022
10023 pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
10024 if !self.state.is_alive() {
10025 anyhow::bail!("you are dead");
10026 }
10027 self.seq += 1;
10028 self.session
10029 .submit_intent(Intent::Use {
10030 entity_id: self.state.entity_id,
10031 template_id: template_id.to_string(),
10032 seq: self.seq,
10033 })
10034 .await?;
10035 self.state.intents_sent += 1;
10036 Ok(())
10037 }
10038
10039 pub async fn use_grant(
10041 &mut self,
10042 grant_instance_id: uuid::Uuid,
10043 target_instance_id: uuid::Uuid,
10044 ) -> anyhow::Result<()> {
10045 if !self.state.is_alive() {
10046 anyhow::bail!("you are dead");
10047 }
10048 self.seq += 1;
10049 self.session
10050 .submit_intent(Intent::UseGrant {
10051 entity_id: self.state.entity_id,
10052 grant_instance_id,
10053 target_instance_id,
10054 seq: self.seq,
10055 })
10056 .await?;
10057 self.state.intents_sent += 1;
10058 Ok(())
10059 }
10060
10061 pub fn open_craft_menu(&mut self) {
10062 self.state.show_craft_menu = true;
10063 self.state.show_shop_menu = false;
10064 self.state.shop_catalog = None;
10065 self.state.show_stats = false;
10066 self.state.show_inventory_menu = false;
10067 self.state.reload_craft_prefs();
10068 self.state.craft_tab = CraftTab::Ready;
10069 self.state.craft_filter.clear();
10070 self.state.craft_filter_focused = false;
10071 self.state.craft_menu_index = 0;
10072 self.state.clamp_craft_menu_index();
10073 self.state.craft_batch_quantity = 1;
10074 self.state.clamp_craft_batch_quantity();
10075 }
10076
10077 pub fn close_craft_menu(&mut self) {
10078 self.state.show_craft_menu = false;
10079 self.state.craft_filter_focused = false;
10080 }
10081
10082 pub fn toggle_keychain_menu(&mut self) {
10083 if self.state.show_keychain_menu {
10084 self.close_keychain_menu();
10085 } else {
10086 self.state.show_keychain_menu = true;
10087 self.state.show_craft_menu = false;
10088 self.state.show_shop_menu = false;
10089 self.state.show_inventory_menu = false;
10090 let n = self.state.keychain_entries().len();
10091 if n == 0 {
10092 self.state.keychain_menu_index = 0;
10093 } else {
10094 self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
10095 }
10096 }
10097 }
10098
10099 pub fn close_keychain_menu(&mut self) {
10100 self.state.show_keychain_menu = false;
10101 }
10102
10103 pub fn keychain_menu_move(&mut self, delta: i32) {
10104 let n = self.state.keychain_entries().len();
10105 if n == 0 {
10106 self.state.keychain_menu_index = 0;
10107 return;
10108 }
10109 let idx = self.state.keychain_menu_index as i32 + delta;
10110 self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
10111 }
10112
10113 pub fn keychain_menu_page(&mut self, pages: i32) {
10114 let n = self.state.keychain_entries().len();
10115 self.state.keychain_menu_index = page_list_index(self.state.keychain_menu_index, pages, n);
10116 }
10117
10118 pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
10119 if !self.state.is_alive() {
10120 anyhow::bail!("you are dead");
10121 }
10122 let entries = self.state.keychain_entries();
10123 let Some(entry) = entries.get(self.state.keychain_menu_index) else {
10124 anyhow::bail!("nothing selected");
10125 };
10126 let Some(instance_id) = entry.stack.item_instance_id else {
10127 anyhow::bail!("key has no instance id");
10128 };
10129 if entry.stowed {
10130 self.move_item(
10131 instance_id,
10132 flatland_protocol::InventoryLocation::Keychain,
10133 flatland_protocol::InventoryLocation::Root,
10134 None,
10135 Some(1),
10136 )
10137 .await
10138 } else {
10139 self.move_item(
10140 instance_id,
10141 flatland_protocol::InventoryLocation::Root,
10142 flatland_protocol::InventoryLocation::Keychain,
10143 None,
10144 Some(1),
10145 )
10146 .await
10147 }
10148 }
10149
10150 pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
10151 let npc_id = self.state.shop_catalog.as_ref().map(|c| c.npc_id.clone());
10152 self.state.show_shop_menu = false;
10153 self.state.shop_catalog = None;
10154 self.state.clear_shop_trade_log();
10155 if let Some(npc_id) = npc_id {
10156 self.seq += 1;
10157 self.session
10158 .submit_intent(Intent::ShopClose {
10159 entity_id: self.state.entity_id,
10160 npc_id,
10161 seq: self.seq,
10162 })
10163 .await?;
10164 self.state.intents_sent += 1;
10165 }
10166 Ok(())
10167 }
10168
10169 pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
10170 let Some(panel) = self.state.bank_panel.clone() else {
10171 return Ok(());
10172 };
10173 self.seq += 1;
10174 self.session
10175 .submit_intent(Intent::BankDeposit {
10176 entity_id: self.state.entity_id,
10177 npc_id: panel.npc_id,
10178 amount_copper,
10179 seq: self.seq,
10180 })
10181 .await?;
10182 self.state.intents_sent += 1;
10183 Ok(())
10184 }
10185
10186 pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
10187 let Some(panel) = self.state.bank_panel.clone() else {
10188 return Ok(());
10189 };
10190 self.seq += 1;
10191 self.session
10192 .submit_intent(Intent::BankWithdraw {
10193 entity_id: self.state.entity_id,
10194 npc_id: panel.npc_id,
10195 amount_copper,
10196 seq: self.seq,
10197 })
10198 .await?;
10199 self.state.intents_sent += 1;
10200 Ok(())
10201 }
10202
10203 pub async fn bank_transfer(
10204 &mut self,
10205 to_character_id: Option<uuid::Uuid>,
10206 to_name: String,
10207 amount_copper: u64,
10208 ) -> anyhow::Result<()> {
10209 let Some(panel) = self.state.bank_panel.clone() else {
10210 return Ok(());
10211 };
10212 self.seq += 1;
10213 self.session
10214 .submit_intent(Intent::BankTransfer {
10215 entity_id: self.state.entity_id,
10216 npc_id: panel.npc_id,
10217 to_character_id,
10218 to_name,
10219 amount_copper,
10220 seq: self.seq,
10221 })
10222 .await?;
10223 self.state.intents_sent += 1;
10224 Ok(())
10225 }
10226
10227 pub fn bank_menu_move(&mut self, delta: i32) {
10228 let n = self.state.bank_menu_options().len();
10229 if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
10230 return;
10231 }
10232 let idx = self.state.bank_menu_index as i32;
10233 self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10234 }
10235
10236 pub fn storage_menu_move(&mut self, delta: i32) {
10237 let n = self.state.storage_menu_options().len();
10238 if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
10239 return;
10240 }
10241 let idx = self.state.storage_menu_index as i32;
10242 self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10243 }
10244
10245 pub fn storage_pick_move(&mut self, delta: i32) {
10246 let n = match &self.state.storage_ui_mode {
10247 StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
10248 StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
10249 self.state.storage_vault_options().len()
10250 }
10251 StorageUiMode::Menu
10252 | StorageUiMode::StoreAmount { .. }
10253 | StorageUiMode::TakeAmount { .. }
10254 | StorageUiMode::ShipAmount { .. } => 0,
10255 };
10256 if n == 0 {
10257 return;
10258 }
10259 match &mut self.state.storage_ui_mode {
10260 StorageUiMode::StorePick { index }
10261 | StorageUiMode::TakePick { index }
10262 | StorageUiMode::ShipPick { index, .. } => {
10263 *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
10264 }
10265 StorageUiMode::Menu
10266 | StorageUiMode::StoreAmount { .. }
10267 | StorageUiMode::TakeAmount { .. }
10268 | StorageUiMode::ShipAmount { .. } => {}
10269 }
10270 }
10271
10272 pub fn storage_ui_back(&mut self) {
10273 self.state.storage_ui_mode = match &self.state.storage_ui_mode {
10274 StorageUiMode::StoreAmount { pick_index, .. } => {
10275 StorageUiMode::StorePick { index: *pick_index }
10276 }
10277 StorageUiMode::TakeAmount { pick_index, .. } => {
10278 StorageUiMode::TakePick { index: *pick_index }
10279 }
10280 StorageUiMode::ShipAmount {
10281 dest_building_id,
10282 dest_label,
10283 pick_index,
10284 ..
10285 } => StorageUiMode::ShipPick {
10286 dest_building_id: dest_building_id.clone(),
10287 dest_label: dest_label.clone(),
10288 index: *pick_index,
10289 },
10290 StorageUiMode::StorePick { .. }
10291 | StorageUiMode::TakePick { .. }
10292 | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
10293 StorageUiMode::Menu => StorageUiMode::Menu,
10294 };
10295 }
10296
10297 pub fn storage_amount_append_char(&mut self, c: char) {
10298 match &mut self.state.storage_ui_mode {
10299 StorageUiMode::StoreAmount { input, .. }
10300 | StorageUiMode::TakeAmount { input, .. }
10301 | StorageUiMode::ShipAmount { input, .. } => {
10302 if c.is_ascii_digit() && input.len() < 8 {
10303 input.push(c);
10304 }
10305 }
10306 _ => {}
10307 }
10308 }
10309
10310 pub fn storage_amount_backspace(&mut self) {
10311 match &mut self.state.storage_ui_mode {
10312 StorageUiMode::StoreAmount { input, .. }
10313 | StorageUiMode::TakeAmount { input, .. }
10314 | StorageUiMode::ShipAmount { input, .. } => {
10315 input.pop();
10316 }
10317 _ => {}
10318 }
10319 }
10320
10321 pub fn storage_ui_typing(&self) -> bool {
10322 matches!(
10323 self.state.storage_ui_mode,
10324 StorageUiMode::StoreAmount { .. }
10325 | StorageUiMode::TakeAmount { .. }
10326 | StorageUiMode::ShipAmount { .. }
10327 )
10328 }
10329
10330 pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
10331 match self.state.storage_ui_mode.clone() {
10332 StorageUiMode::Menu => {
10333 let index = self.state.storage_menu_index;
10334 match index {
10335 0 => {
10336 let opts = self.state.storage_store_options();
10337 if opts.is_empty() {
10338 self.state.push_log("Nothing loose to store.");
10339 return Ok(());
10340 }
10341 self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
10342 }
10343 1 => {
10344 let opts = self.state.storage_vault_options();
10345 if opts.is_empty() {
10346 self.state.push_log("Vault is empty.");
10347 return Ok(());
10348 }
10349 self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
10350 }
10351 n => {
10352 let dest = self
10353 .state
10354 .storage_panel
10355 .as_ref()
10356 .and_then(|p| p.ship_destinations.get(n - 2))
10357 .cloned();
10358 let Some(dest) = dest else {
10359 return Ok(());
10360 };
10361 let opts = self.state.storage_vault_options();
10362 if opts.is_empty() {
10363 self.state.push_log("Vault is empty — nothing to ship.");
10364 return Ok(());
10365 }
10366 self.state.storage_ui_mode = StorageUiMode::ShipPick {
10367 dest_building_id: dest.building_id,
10368 dest_label: dest.label,
10369 index: 0,
10370 };
10371 }
10372 }
10373 }
10374 StorageUiMode::StorePick { index } => {
10375 let opts = self.state.storage_store_options();
10376 let Some(opt) = opts.get(index) else {
10377 self.state.push_log("Nothing loose to store.");
10378 self.state.storage_ui_mode = StorageUiMode::Menu;
10379 return Ok(());
10380 };
10381 self.state.storage_ui_mode = StorageUiMode::StoreAmount {
10382 pick_index: index,
10383 item_instance_id: opt.item_instance_id,
10384 label: opt.label.clone(),
10385 max_qty: opt.quantity.max(1),
10386 input: String::new(),
10387 };
10388 }
10389 StorageUiMode::TakePick { index } => {
10390 let opts = self.state.storage_vault_options();
10391 let Some(opt) = opts.get(index) else {
10392 self.state.push_log("Vault is empty.");
10393 self.state.storage_ui_mode = StorageUiMode::Menu;
10394 return Ok(());
10395 };
10396 self.state.storage_ui_mode = StorageUiMode::TakeAmount {
10397 pick_index: index,
10398 item_instance_id: opt.item_instance_id,
10399 label: opt.label.clone(),
10400 max_qty: opt.quantity.max(1),
10401 input: String::new(),
10402 };
10403 }
10404 StorageUiMode::ShipPick {
10405 dest_building_id,
10406 dest_label,
10407 index,
10408 } => {
10409 let opts = self.state.storage_vault_options();
10410 let Some(opt) = opts.get(index) else {
10411 self.state.push_log("Vault is empty — nothing to ship.");
10412 self.state.storage_ui_mode = StorageUiMode::Menu;
10413 return Ok(());
10414 };
10415 self.state.storage_ui_mode = StorageUiMode::ShipAmount {
10416 dest_building_id,
10417 dest_label,
10418 pick_index: index,
10419 item_instance_id: opt.item_instance_id,
10420 label: opt.label.clone(),
10421 max_qty: opt.quantity.max(1),
10422 input: String::new(),
10423 };
10424 }
10425 StorageUiMode::StoreAmount {
10426 item_instance_id,
10427 max_qty,
10428 input,
10429 ..
10430 } => {
10431 let Some(qty) = parse_storage_quantity(&input) else {
10432 self.state.push_log("Enter a quantity (blank or 0 = all).");
10433 return Ok(());
10434 };
10435 let qty = qty.map(|n| n.min(max_qty).max(1));
10436 self.storage_store(item_instance_id, qty).await?;
10437 self.state.storage_ui_mode = StorageUiMode::Menu;
10438 }
10439 StorageUiMode::TakeAmount {
10440 item_instance_id,
10441 max_qty,
10442 input,
10443 ..
10444 } => {
10445 let Some(qty) = parse_storage_quantity(&input) else {
10446 self.state.push_log("Enter a quantity (blank or 0 = all).");
10447 return Ok(());
10448 };
10449 let qty = qty.map(|n| n.min(max_qty).max(1));
10450 self.storage_take(item_instance_id, qty).await?;
10451 self.state.storage_ui_mode = StorageUiMode::Menu;
10452 }
10453 StorageUiMode::ShipAmount {
10454 dest_building_id,
10455 item_instance_id,
10456 max_qty,
10457 input,
10458 ..
10459 } => {
10460 let Some(qty) = parse_storage_quantity(&input) else {
10461 self.state.push_log("Enter a quantity (blank or 0 = all).");
10462 return Ok(());
10463 };
10464 let qty = qty.map(|n| n.min(max_qty).max(1));
10465 self.storage_ship(dest_building_id, item_instance_id, qty)
10466 .await?;
10467 self.state.storage_ui_mode = StorageUiMode::Menu;
10468 }
10469 }
10470 Ok(())
10471 }
10472
10473 pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
10474 match self.state.bank_ui_mode.clone() {
10475 BankUiMode::Menu => {
10476 let choice = self
10477 .state
10478 .bank_menu_options()
10479 .get(self.state.bank_menu_index)
10480 .copied()
10481 .unwrap_or("Deposit…");
10482 match choice {
10483 "Withdraw…" => {
10484 self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
10485 input: String::new(),
10486 };
10487 }
10488 "Deposit all" => self.bank_deposit(0).await?,
10489 "Withdraw all" => self.bank_withdraw(0).await?,
10490 "Transfer…" => {
10491 self.state.bank_ui_mode = BankUiMode::TransferName {
10492 input: String::new(),
10493 };
10494 }
10495 _ => {
10496 self.state.bank_ui_mode = BankUiMode::DepositAmount {
10497 input: String::new(),
10498 };
10499 }
10500 }
10501 }
10502 BankUiMode::DepositAmount { input } => {
10503 let Some(amount) = parse_bank_copper_amount(&input) else {
10504 self.state
10505 .push_log("Enter a copper amount (blank or 0 = everything on person).");
10506 return Ok(());
10507 };
10508 self.bank_deposit(amount).await?;
10509 self.state.bank_ui_mode = BankUiMode::Menu;
10510 }
10511 BankUiMode::WithdrawAmount { input } => {
10512 let Some(amount) = parse_bank_copper_amount(&input) else {
10513 self.state
10514 .push_log("Enter a copper amount (blank or 0 = full ledger).");
10515 return Ok(());
10516 };
10517 self.bank_withdraw(amount).await?;
10518 self.state.bank_ui_mode = BankUiMode::Menu;
10519 }
10520 BankUiMode::TransferName { input } => {
10521 let name = input.trim().to_string();
10522 if name.is_empty() {
10523 self.state.push_log("Enter the recipient character name.");
10524 return Ok(());
10525 }
10526 self.state.bank_ui_mode = BankUiMode::TransferAmount {
10527 to_name: name,
10528 input: String::new(),
10529 };
10530 }
10531 BankUiMode::TransferAmount { to_name, input } => {
10532 let amount: u64 = match input.trim().parse() {
10533 Ok(v) if v > 0 => v,
10534 _ => {
10535 self.state
10536 .push_log("Enter a positive copper amount to transfer.");
10537 return Ok(());
10538 }
10539 };
10540 self.bank_transfer(None, to_name, amount).await?;
10541 self.state.bank_ui_mode = BankUiMode::Menu;
10542 }
10543 }
10544 Ok(())
10545 }
10546
10547 pub fn bank_transfer_back(&mut self) {
10548 match &self.state.bank_ui_mode {
10549 BankUiMode::TransferAmount { to_name, .. } => {
10550 self.state.bank_ui_mode = BankUiMode::TransferName {
10551 input: to_name.clone(),
10552 };
10553 }
10554 BankUiMode::TransferName { .. }
10555 | BankUiMode::DepositAmount { .. }
10556 | BankUiMode::WithdrawAmount { .. } => {
10557 self.state.bank_ui_mode = BankUiMode::Menu;
10558 }
10559 BankUiMode::Menu => {}
10560 }
10561 }
10562
10563 pub fn bank_transfer_append_char(&mut self, c: char) {
10564 match &mut self.state.bank_ui_mode {
10565 BankUiMode::TransferName { input } => {
10566 if input.len() < 32 && !c.is_control() {
10567 input.push(c);
10568 }
10569 }
10570 BankUiMode::DepositAmount { input }
10571 | BankUiMode::WithdrawAmount { input }
10572 | BankUiMode::TransferAmount { input, .. } => {
10573 if c.is_ascii_digit() && input.len() < 12 {
10574 input.push(c);
10575 }
10576 }
10577 BankUiMode::Menu => {}
10578 }
10579 }
10580
10581 pub fn bank_transfer_backspace(&mut self) {
10582 match &mut self.state.bank_ui_mode {
10583 BankUiMode::TransferName { input }
10584 | BankUiMode::DepositAmount { input }
10585 | BankUiMode::WithdrawAmount { input }
10586 | BankUiMode::TransferAmount { input, .. } => {
10587 input.pop();
10588 }
10589 BankUiMode::Menu => {}
10590 }
10591 }
10592
10593 pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
10594 let npc_id = self.state.bank_panel.as_ref().map(|p| p.npc_id.clone());
10595 self.state.clear_bank_panel();
10596 if let Some(npc_id) = npc_id {
10597 self.seq += 1;
10598 self.session
10599 .submit_intent(Intent::BankClose {
10600 entity_id: self.state.entity_id,
10601 npc_id,
10602 seq: self.seq,
10603 })
10604 .await?;
10605 self.state.intents_sent += 1;
10606 }
10607 Ok(())
10608 }
10609
10610 pub async fn storage_store(
10611 &mut self,
10612 item_instance_id: uuid::Uuid,
10613 quantity: Option<u32>,
10614 ) -> anyhow::Result<()> {
10615 let Some(panel) = self.state.storage_panel.clone() else {
10616 return Ok(());
10617 };
10618 self.seq += 1;
10619 self.session
10620 .submit_intent(Intent::StorageStore {
10621 entity_id: self.state.entity_id,
10622 npc_id: panel.npc_id,
10623 item_instance_id,
10624 quantity,
10625 seq: self.seq,
10626 })
10627 .await?;
10628 self.state.intents_sent += 1;
10629 Ok(())
10630 }
10631
10632 pub async fn storage_take(
10633 &mut self,
10634 item_instance_id: uuid::Uuid,
10635 quantity: Option<u32>,
10636 ) -> anyhow::Result<()> {
10637 let Some(panel) = self.state.storage_panel.clone() else {
10638 return Ok(());
10639 };
10640 self.seq += 1;
10641 self.session
10642 .submit_intent(Intent::StorageTake {
10643 entity_id: self.state.entity_id,
10644 npc_id: panel.npc_id,
10645 item_instance_id,
10646 quantity,
10647 seq: self.seq,
10648 })
10649 .await?;
10650 self.state.intents_sent += 1;
10651 Ok(())
10652 }
10653
10654 pub async fn storage_ship(
10655 &mut self,
10656 dest_building_id: String,
10657 item_instance_id: uuid::Uuid,
10658 quantity: Option<u32>,
10659 ) -> anyhow::Result<()> {
10660 let Some(panel) = self.state.storage_panel.clone() else {
10661 return Ok(());
10662 };
10663 self.seq += 1;
10664 self.session
10665 .submit_intent(Intent::StorageShip {
10666 entity_id: self.state.entity_id,
10667 npc_id: panel.npc_id,
10668 dest_building_id,
10669 item_instance_id,
10670 quantity,
10671 seq: self.seq,
10672 })
10673 .await?;
10674 self.state.intents_sent += 1;
10675 Ok(())
10676 }
10677
10678 pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
10679 let npc_id = self.state.storage_panel.as_ref().map(|p| p.npc_id.clone());
10680 self.state.clear_storage_panel();
10681 if let Some(npc_id) = npc_id {
10682 self.seq += 1;
10683 self.session
10684 .submit_intent(Intent::StorageClose {
10685 entity_id: self.state.entity_id,
10686 npc_id,
10687 seq: self.seq,
10688 })
10689 .await?;
10690 self.state.intents_sent += 1;
10691 }
10692 Ok(())
10693 }
10694
10695 pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
10696 let npc_id = self.state.market_panel.as_ref().map(|p| p.npc_id.clone());
10697 self.state.clear_market_panel();
10698 if let Some(npc_id) = npc_id {
10699 self.seq += 1;
10700 self.session
10701 .submit_intent(Intent::MarketClose {
10702 entity_id: self.state.entity_id,
10703 npc_id,
10704 seq: self.seq,
10705 })
10706 .await?;
10707 self.state.intents_sent += 1;
10708 }
10709 Ok(())
10710 }
10711
10712 pub fn market_move_selection(&mut self, delta: i32) {
10713 let indices = self.state.market_filtered_listing_indices();
10714 let n = indices.len();
10715 if n == 0 {
10716 self.state.market_menu_index = 0;
10717 return;
10718 }
10719 let cur = self.state.market_menu_index as i32;
10720 self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
10721 }
10722
10723 pub fn market_page_selection(&mut self, pages: i32) {
10724 let indices = self.state.market_filtered_listing_indices();
10725 let n = indices.len();
10726 if n == 0 {
10727 self.state.market_menu_index = 0;
10728 return;
10729 }
10730 self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
10731 }
10732
10733 pub fn market_list_page(&mut self, pages: i32) {
10734 match &self.state.market_ui_mode {
10735 MarketUiMode::ListSource { index } => {
10736 let n = self.state.market_list_source_options().len();
10737 if n == 0 {
10738 return;
10739 }
10740 let next = page_list_index(*index, pages, n);
10741 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
10742 }
10743 MarketUiMode::ListPricingMode { index, .. } => {
10744 let next = page_list_index(*index, pages, 2);
10745 if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
10746 {
10747 *index = next;
10748 }
10749 }
10750 MarketUiMode::ListPick { source, index } => {
10751 let opts = self.state.market_list_item_options(source);
10752 let n = opts.len();
10753 if n == 0 {
10754 return;
10755 }
10756 let next = page_list_index(*index, pages, n);
10757 self.state.market_ui_mode = MarketUiMode::ListPick {
10758 source: source.clone(),
10759 index: next,
10760 };
10761 }
10762 _ => {}
10763 }
10764 }
10765
10766 pub fn market_cycle_category(&mut self, delta: i32) {
10767 let groups = self.state.market_available_category_groups();
10768 let mut labels: Vec<Option<&'static str>> = vec![None];
10770 labels.extend(groups.into_iter().map(Some));
10771 let n = labels.len() as i32;
10772 let cur = labels
10773 .iter()
10774 .position(|g| *g == self.state.market_category_filter)
10775 .unwrap_or(0) as i32;
10776 let next = (cur + delta).rem_euclid(n) as usize;
10777 self.state.market_category_filter = labels[next];
10778 self.state.market_menu_index = 0;
10779 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10780 let source = source.clone();
10781 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10782 }
10783 }
10784
10785 pub fn focus_market_filter(&mut self) {
10786 self.state.market_filter_focused = true;
10787 }
10788
10789 pub fn append_market_filter_char(&mut self, ch: char) {
10790 if !self.state.market_filter_focused {
10791 return;
10792 }
10793 if !is_list_filter_char(ch) {
10794 return;
10795 }
10796 if self.state.market_filter.len() < 48 {
10797 self.state.market_filter.push(ch);
10798 self.state.market_menu_index = 0;
10799 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10800 let source = source.clone();
10801 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10802 }
10803 }
10804 }
10805
10806 pub fn market_filter_backspace(&mut self) {
10807 if !self.state.market_filter_focused {
10808 return;
10809 }
10810 self.state.market_filter.pop();
10811 self.state.market_menu_index = 0;
10812 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10813 let source = source.clone();
10814 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10815 }
10816 }
10817
10818 pub fn clear_or_blur_market_filter(&mut self) -> bool {
10820 if self.state.market_filter_focused {
10821 if !self.state.market_filter.is_empty() {
10822 self.state.market_filter.clear();
10823 self.state.market_menu_index = 0;
10824 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10825 let source = source.clone();
10826 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10827 }
10828 return true;
10829 }
10830 self.state.market_filter_focused = false;
10831 return true;
10832 }
10833 if !self.state.market_filter.is_empty() {
10834 self.state.market_filter.clear();
10835 self.state.market_menu_index = 0;
10836 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10837 let source = source.clone();
10838 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10839 }
10840 return true;
10841 }
10842 false
10843 }
10844
10845 pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
10846 if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
10847 return self.market_confirm_buy(listing_id, qty).await;
10848 }
10849 let Some(panel) = self.state.market_panel.clone() else {
10850 return Ok(());
10851 };
10852 let indices = self.state.market_filtered_listing_indices();
10853 let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
10854 return Ok(());
10855 };
10856 let Some(listing) = panel.listings.get(raw_idx) else {
10857 return Ok(());
10858 };
10859 if listing.mine {
10860 self.seq += 1;
10861 self.session
10862 .submit_intent(Intent::MarketDelist {
10863 entity_id: self.state.entity_id,
10864 npc_id: panel.npc_id.clone(),
10865 listing_id: listing.listing_id,
10866 dest: flatland_protocol::GoodsLocation::Person,
10867 seq: self.seq,
10868 })
10869 .await?;
10870 self.state.intents_sent += 1;
10871 return Ok(());
10872 }
10873 if listing.npc_price {
10874 self.state
10875 .push_log("NPC-price listings are bought by merchants only.");
10876 return Ok(());
10877 }
10878 let qty = 1u32.min(listing.quantity).max(1);
10879 let line = listing.unit_price_copper.saturating_mul(qty as u64);
10880 self.state.market_buy_confirm = Some((
10881 listing.listing_id,
10882 qty,
10883 listing.unit_price_copper,
10884 line,
10885 listing.display_name.clone(),
10886 ));
10887 Ok(())
10888 }
10889
10890 pub async fn market_confirm_buy(
10891 &mut self,
10892 listing_id: uuid::Uuid,
10893 quantity: u32,
10894 ) -> anyhow::Result<()> {
10895 let Some(panel) = self.state.market_panel.clone() else {
10896 self.state.market_buy_confirm = None;
10897 return Ok(());
10898 };
10899 self.state.market_buy_confirm = None;
10900 self.seq += 1;
10901 self.session
10902 .submit_intent(Intent::MarketBuy {
10903 entity_id: self.state.entity_id,
10904 npc_id: panel.npc_id,
10905 listing_id,
10906 quantity,
10907 dest: flatland_protocol::GoodsLocation::Person,
10908 seq: self.seq,
10909 })
10910 .await?;
10911 self.state.intents_sent += 1;
10912 Ok(())
10913 }
10914
10915 pub fn market_begin_list(&mut self) {
10917 if self.state.market_panel.is_none() {
10918 return;
10919 }
10920 let sources = self.state.market_list_source_options();
10921 if sources.is_empty() {
10922 self.state.push_log("Nothing to list from.");
10923 return;
10924 }
10925 if sources.len() == 1 {
10927 let (source, _) = sources[0].clone();
10928 let opts = self.state.market_list_item_options(&source);
10929 if opts.is_empty() {
10930 self.state.push_log("Nothing loose to list.");
10931 return;
10932 }
10933 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10934 self.state.market_buy_confirm = None;
10935 return;
10936 }
10937 self.state.market_buy_confirm = None;
10938 self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
10939 }
10940
10941 pub fn market_ui_back(&mut self) {
10942 self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
10943 MarketUiMode::Browse => MarketUiMode::Browse,
10944 MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
10945 MarketUiMode::ListPick { .. } => {
10946 if self.state.market_list_source_options().len() <= 1 {
10947 MarketUiMode::Browse
10948 } else {
10949 MarketUiMode::ListSource { index: 0 }
10950 }
10951 }
10952 MarketUiMode::ListAmount {
10953 source, pick_index, ..
10954 } => MarketUiMode::ListPick {
10955 source,
10956 index: pick_index,
10957 },
10958 MarketUiMode::ListPricingMode {
10959 source,
10960 item_instance_id,
10961 template_id,
10962 label,
10963 max_qty,
10964 quantity,
10965 pick_index,
10966 ..
10967 } => {
10968 let input = quantity.map(|q| q.to_string()).unwrap_or_default();
10969 MarketUiMode::ListAmount {
10970 source,
10971 pick_index,
10972 item_instance_id,
10973 template_id,
10974 label,
10975 max_qty,
10976 input,
10977 }
10978 }
10979 MarketUiMode::ListPrice {
10980 source,
10981 pick_index,
10982 item_instance_id,
10983 template_id,
10984 label,
10985 max_qty,
10986 quantity,
10987 ..
10988 } => MarketUiMode::ListPricingMode {
10989 source,
10990 pick_index,
10991 item_instance_id,
10992 template_id,
10993 label,
10994 quantity,
10995 max_qty,
10996 index: 1,
10997 },
10998 };
10999 }
11000
11001 pub fn market_list_move(&mut self, delta: i32) {
11002 match &self.state.market_ui_mode {
11003 MarketUiMode::ListSource { index } => {
11004 let n = self.state.market_list_source_options().len();
11005 if n == 0 {
11006 return;
11007 }
11008 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
11009 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
11010 }
11011 MarketUiMode::ListPricingMode { index, .. } => {
11012 let next = (*index as i32 + delta).rem_euclid(2) as usize;
11013 if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
11014 {
11015 *index = next;
11016 }
11017 }
11018 MarketUiMode::ListPick { source, index } => {
11019 let opts = self.state.market_list_item_options(source);
11020 let n = opts.len();
11021 if n == 0 {
11022 return;
11023 }
11024 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
11025 self.state.market_ui_mode = MarketUiMode::ListPick {
11026 source: source.clone(),
11027 index: next,
11028 };
11029 }
11030 _ => {}
11031 }
11032 }
11033
11034 pub fn market_list_amount_append_char(&mut self, c: char) {
11035 if !c.is_ascii_digit() {
11036 return;
11037 }
11038 match &mut self.state.market_ui_mode {
11039 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
11040 if input.len() < 12 {
11041 input.push(c);
11042 }
11043 }
11044 _ => {}
11045 }
11046 }
11047
11048 pub fn market_list_amount_backspace(&mut self) {
11049 match &mut self.state.market_ui_mode {
11050 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
11051 input.pop();
11052 }
11053 _ => {}
11054 }
11055 }
11056
11057 pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
11058 match self.state.market_ui_mode.clone() {
11059 MarketUiMode::Browse => Ok(()),
11060 MarketUiMode::ListSource { index } => {
11061 let sources = self.state.market_list_source_options();
11062 let Some((source, _)) = sources.get(index).cloned() else {
11063 return Ok(());
11064 };
11065 let opts = self.state.market_list_item_options(&source);
11066 if opts.is_empty() {
11067 self.state.push_log("Nothing to list from that source.");
11068 return Ok(());
11069 }
11070 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
11071 Ok(())
11072 }
11073 MarketUiMode::ListPick { source, index } => {
11074 let opts = self.state.market_list_item_options(&source);
11075 let Some(opt) = opts.get(index) else {
11076 self.state.push_log("Nothing to list.");
11077 self.state.market_ui_mode = MarketUiMode::Browse;
11078 return Ok(());
11079 };
11080 self.state.market_ui_mode = MarketUiMode::ListAmount {
11081 source,
11082 pick_index: index,
11083 item_instance_id: opt.item_instance_id,
11084 template_id: opt.template_id.clone(),
11085 label: opt.label.clone(),
11086 max_qty: opt.quantity.max(1),
11087 input: String::new(),
11088 };
11089 Ok(())
11090 }
11091 MarketUiMode::ListAmount {
11092 source,
11093 pick_index,
11094 item_instance_id,
11095 template_id,
11096 label,
11097 max_qty,
11098 input,
11099 ..
11100 } => {
11101 let Some(qty_opt) = parse_storage_quantity(&input) else {
11102 self.state.push_log("Enter a quantity (blank = all).");
11103 return Ok(());
11104 };
11105 if let Some(q) = qty_opt {
11106 if q > max_qty {
11107 self.state.push_log(format!("Only {max_qty} available."));
11108 return Ok(());
11109 }
11110 }
11111 self.state.market_ui_mode = MarketUiMode::ListPricingMode {
11112 source,
11113 pick_index,
11114 item_instance_id,
11115 template_id,
11116 label,
11117 quantity: qty_opt,
11118 max_qty,
11119 index: 0,
11120 };
11121 Ok(())
11122 }
11123 MarketUiMode::ListPricingMode {
11124 source,
11125 pick_index,
11126 item_instance_id,
11127 template_id,
11128 label,
11129 quantity,
11130 max_qty,
11131 index,
11132 } => {
11133 if index == 0 {
11134 if self
11135 .state
11136 .npc_market_dump_unit_estimate(&template_id)
11137 .is_none()
11138 {
11139 self.state
11140 .push_log("That item has no NPC value — use a fixed price instead.");
11141 return Ok(());
11142 }
11143 return self
11144 .submit_market_list_intent(
11145 source,
11146 item_instance_id,
11147 quantity,
11148 0,
11149 true,
11150 &label,
11151 )
11152 .await;
11153 }
11154 self.state.market_ui_mode = MarketUiMode::ListPrice {
11155 source,
11156 pick_index,
11157 item_instance_id,
11158 template_id,
11159 label,
11160 quantity,
11161 max_qty,
11162 input: String::new(),
11163 };
11164 Ok(())
11165 }
11166 MarketUiMode::ListPrice {
11167 source,
11168 item_instance_id,
11169 label,
11170 quantity,
11171 input,
11172 ..
11173 } => {
11174 let price = input.trim().parse::<u64>().unwrap_or(0);
11175 if price == 0 {
11176 self.state
11177 .push_log("Enter a unit price of at least 1 copper.");
11178 return Ok(());
11179 }
11180 self.submit_market_list_intent(
11181 source,
11182 item_instance_id,
11183 quantity,
11184 price,
11185 false,
11186 &label,
11187 )
11188 .await
11189 }
11190 }
11191 }
11192
11193 async fn submit_market_list_intent(
11194 &mut self,
11195 source: MarketListSourceKind,
11196 item_instance_id: uuid::Uuid,
11197 quantity: Option<u32>,
11198 unit_price_copper: u64,
11199 npc_price: bool,
11200 label: &str,
11201 ) -> anyhow::Result<()> {
11202 let Some(panel) = self.state.market_panel.clone() else {
11203 self.state.market_ui_mode = MarketUiMode::Browse;
11204 return Ok(());
11205 };
11206 let goods = match source {
11207 MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
11208 MarketListSourceKind::TownStorage { building_id } => {
11209 flatland_protocol::GoodsLocation::TownStorage { building_id }
11210 }
11211 };
11212 self.seq += 1;
11213 self.session
11214 .submit_intent(Intent::MarketList {
11215 entity_id: self.state.entity_id,
11216 npc_id: panel.npc_id,
11217 source: goods,
11218 item_instance_id,
11219 quantity,
11220 unit_price_copper,
11221 npc_price,
11222 seq: self.seq,
11223 })
11224 .await?;
11225 self.state.intents_sent += 1;
11226 if npc_price {
11227 self.state
11228 .push_log(format!("Listing {label} at NPC price…"));
11229 } else {
11230 self.state
11231 .push_log(format!("Listing {label} @ {unit_price_copper} cp…"));
11232 }
11233 self.state.market_ui_mode = MarketUiMode::Browse;
11234 Ok(())
11235 }
11236
11237 pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
11239 let return_to_verbs = self.state.npc_verb_target.is_some();
11240 self.close_shop_menu().await?;
11241 if return_to_verbs {
11242 self.state.show_npc_verb_menu = true;
11243 self.state.npc_verb_notice = None;
11244 }
11245 Ok(())
11246 }
11247
11248 pub fn shop_tab_toggle(&mut self) {
11249 self.state.shop_tab = match self.state.shop_tab {
11250 ShopTab::Buy => ShopTab::Sell,
11251 ShopTab::Sell => ShopTab::Buy,
11252 };
11253 self.state.shop_menu_index = 0;
11254 if self.state.shop_tab == ShopTab::Sell {
11255 self.state.shop_quantity_set_max();
11256 }
11257 self.state.clamp_shop_selection();
11258 }
11259
11260 pub fn shop_menu_move(&mut self, delta: i32) {
11261 self.state.shop_menu_move(delta);
11262 }
11263
11264 pub fn shop_quantity_adjust(&mut self, delta: i32) {
11265 self.state.shop_quantity_adjust(delta);
11266 }
11267
11268 pub fn shop_quantity_set_max(&mut self) {
11269 self.state.shop_quantity_set_max();
11270 }
11271
11272 pub fn shop_quantity_set_min(&mut self) {
11273 self.state.shop_quantity_set_min();
11274 }
11275
11276 pub fn toggle_quest_menu(&mut self) {
11277 self.state.show_quest_menu = !self.state.show_quest_menu;
11278 if self.state.show_quest_menu {
11279 self.state.quest_menu_index = 0;
11280 self.state.quest_withdraw_confirm = false;
11281 self.state.show_workers_menu = false;
11282 }
11283 }
11284
11285 pub fn toggle_workers_menu(&mut self) {
11286 if self.state.show_workers_menu {
11287 self.close_workers_menu_ui();
11288 } else {
11289 self.state.show_workers_menu = true;
11290 if self.state.workers_menu_index >= self.state.hired_workers.len() {
11292 self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
11293 }
11294 self.state.show_quest_menu = false;
11295 self.close_worker_give_picker();
11296 self.close_worker_give_target_picker();
11297 self.close_worker_take_picker();
11298 self.close_worker_teach_picker();
11299 self.cancel_worker_rename();
11300 }
11301 }
11302
11303 pub fn close_workers_menu_ui(&mut self) {
11305 self.state.show_workers_menu = false;
11306 self.cancel_worker_dismissal();
11307 self.close_worker_give_picker();
11308 self.close_worker_give_target_picker();
11309 self.close_worker_take_picker();
11310 self.close_worker_teach_picker();
11311 self.cancel_worker_rename();
11312 }
11313
11314 pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
11316 let Some(idx) = self
11317 .state
11318 .hired_workers
11319 .iter()
11320 .position(|w| w.instance_id == instance_id)
11321 else {
11322 anyhow::bail!("worker not found");
11323 };
11324 let label = self.state.hired_workers[idx].label.clone();
11325 self.state.show_workers_menu = true;
11326 self.state.workers_menu_index = idx;
11327 self.state.show_quest_menu = false;
11328 self.close_worker_give_picker();
11329 self.close_worker_give_target_picker();
11330 self.close_worker_take_picker();
11331 self.close_worker_teach_picker();
11332 self.cancel_worker_rename();
11333 self.set_worker_attending(instance_id, true).await?;
11334 self.state
11335 .push_log(format!("Managing {label} — job paused while menu is open"));
11336 Ok(())
11337 }
11338
11339 pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
11341 self.close_workers_menu_ui();
11342 self.release_worker_attend().await
11343 }
11344
11345 async fn set_worker_attending(
11346 &mut self,
11347 instance_id: &str,
11348 attending: bool,
11349 ) -> anyhow::Result<()> {
11350 if attending {
11351 if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
11352 return Ok(());
11353 }
11354 if let Some(prev) = self.state.attending_worker_instance_id.clone() {
11356 if prev != instance_id {
11357 self.send_attend_hired_worker(&prev, false).await?;
11358 }
11359 }
11360 self.send_attend_hired_worker(instance_id, true).await?;
11361 self.state.attending_worker_instance_id = Some(instance_id.to_string());
11362 } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
11363 self.send_attend_hired_worker(instance_id, false).await?;
11364 self.state.attending_worker_instance_id = None;
11365 }
11366 Ok(())
11367 }
11368
11369 pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
11370 let Some(id) = self.state.attending_worker_instance_id.take() else {
11371 return Ok(());
11372 };
11373 self.send_attend_hired_worker(&id, false).await
11374 }
11375
11376 async fn send_attend_hired_worker(
11377 &mut self,
11378 worker_instance_id: &str,
11379 attending: bool,
11380 ) -> anyhow::Result<()> {
11381 self.seq += 1;
11382 self.session
11383 .submit_intent(Intent::AttendHiredWorker {
11384 entity_id: self.state.entity_id,
11385 worker_instance_id: worker_instance_id.to_string(),
11386 attending,
11387 seq: self.seq,
11388 })
11389 .await?;
11390 self.state.intents_sent += 1;
11391 Ok(())
11392 }
11393
11394 pub fn workers_menu_move(&mut self, delta: i32) {
11395 let n = self.state.hired_workers.len();
11396 if n == 0 {
11397 return;
11398 }
11399 let idx = self.state.workers_menu_index as i32;
11400 self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
11401 }
11402
11403 pub fn toggle_workers_menu_compact(&mut self) {
11404 self.state.workers_menu_compact = !self.state.workers_menu_compact;
11405 let mut cfg = crate::client_config::ClientConfig::load();
11406 let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
11407 }
11408
11409 pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
11410 let Some(worker) = self
11411 .state
11412 .hired_workers
11413 .get(self.state.workers_menu_index)
11414 .cloned()
11415 else {
11416 anyhow::bail!("no worker selected");
11417 };
11418 self.dismiss_worker_by_id(&worker.instance_id, &worker.label)
11419 .await
11420 }
11421
11422 pub fn request_worker_dismissal(&mut self) -> anyhow::Result<()> {
11424 let Some(worker) = self
11425 .state
11426 .hired_workers
11427 .get(self.state.workers_menu_index)
11428 .cloned()
11429 else {
11430 anyhow::bail!("no worker selected");
11431 };
11432 self.state.worker_dismiss_confirmation = Some(WorkerDismissConfirmation {
11433 worker_instance_id: worker.instance_id,
11434 worker_label: worker.label,
11435 });
11436 Ok(())
11437 }
11438
11439 pub fn cancel_worker_dismissal(&mut self) {
11440 self.state.worker_dismiss_confirmation = None;
11441 }
11442
11443 pub async fn confirm_worker_dismissal(&mut self) -> anyhow::Result<()> {
11444 let Some(confirm) = self.state.worker_dismiss_confirmation.clone() else {
11445 return Ok(());
11446 };
11447 self.dismiss_worker_by_id(&confirm.worker_instance_id, &confirm.worker_label)
11448 .await?;
11449 self.cancel_worker_dismissal();
11450 Ok(())
11451 }
11452
11453 async fn dismiss_worker_by_id(
11454 &mut self,
11455 worker_instance_id: &str,
11456 worker_label: &str,
11457 ) -> anyhow::Result<()> {
11458 self.seq += 1;
11459 self.session
11460 .submit_intent(Intent::DismissWorker {
11461 entity_id: self.state.entity_id,
11462 worker_instance_id: worker_instance_id.to_string(),
11463 seq: self.seq,
11464 })
11465 .await?;
11466 self.state.intents_sent += 1;
11467 self.state
11468 .hired_workers
11469 .retain(|w| w.instance_id != worker_instance_id);
11470 if self.state.workers_menu_index >= self.state.hired_workers.len() {
11471 self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
11472 }
11473 self.state.push_log(format!("Dismissed {worker_label}"));
11474 Ok(())
11475 }
11476
11477 pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
11478 let Some(worker) = self
11479 .state
11480 .hired_workers
11481 .get(self.state.workers_menu_index)
11482 .cloned()
11483 else {
11484 anyhow::bail!("no worker selected");
11485 };
11486 let mode = match worker.mode {
11487 flatland_protocol::WorkerModeView::Companion => "defender",
11488 flatland_protocol::WorkerModeView::Defender => "job_loop",
11489 flatland_protocol::WorkerModeView::JobLoop => "idle",
11490 flatland_protocol::WorkerModeView::Idle => "companion",
11491 };
11492 self.seq += 1;
11493 self.session
11494 .submit_intent(Intent::SetWorkerMode {
11495 entity_id: self.state.entity_id,
11496 worker_instance_id: worker.instance_id,
11497 mode: mode.into(),
11498 seq: self.seq,
11499 })
11500 .await?;
11501 self.state.intents_sent += 1;
11502 Ok(())
11503 }
11504
11505 pub async fn workers_deliver_selected_to_storage(&mut self) -> anyhow::Result<()> {
11506 let Some(worker) = self
11507 .state
11508 .hired_workers
11509 .get(self.state.workers_menu_index)
11510 .cloned()
11511 else {
11512 anyhow::bail!("no worker selected");
11513 };
11514 if !matches!(worker.mode, flatland_protocol::WorkerModeView::Companion) {
11515 anyhow::bail!("switch the worker to companion mode first");
11516 }
11517 if worker.step_label.starts_with("delivering to ")
11518 || worker.step_label == "returning to you"
11519 {
11520 anyhow::bail!("worker is already delivering to storage");
11521 }
11522 self.seq += 1;
11523 self.session
11524 .submit_intent(Intent::DeliverWorkerToNearestStorage {
11525 entity_id: self.state.entity_id,
11526 worker_instance_id: worker.instance_id.clone(),
11527 seq: self.seq,
11528 })
11529 .await?;
11530 self.state.intents_sent += 1;
11531 self.state.push_log(format!(
11532 "{} is delivering carried items to storage",
11533 worker.label
11534 ));
11535 Ok(())
11536 }
11537
11538 pub async fn workers_cancel_delivery_selected(&mut self) -> anyhow::Result<()> {
11539 let Some(worker) = self
11540 .state
11541 .hired_workers
11542 .get(self.state.workers_menu_index)
11543 .cloned()
11544 else {
11545 anyhow::bail!("no worker selected");
11546 };
11547 if !(worker.step_label.starts_with("delivering to ")
11548 || worker.step_label == "returning to you")
11549 {
11550 anyhow::bail!("worker has no active delivery");
11551 }
11552 self.seq += 1;
11553 self.session
11554 .submit_intent(Intent::CancelWorkerDelivery {
11555 entity_id: self.state.entity_id,
11556 worker_instance_id: worker.instance_id,
11557 seq: self.seq,
11558 })
11559 .await?;
11560 self.state.intents_sent += 1;
11561 self.state
11562 .push_log(format!("Canceled delivery for {}", worker.label));
11563 Ok(())
11564 }
11565
11566 pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
11567 if self.state.hired_workers.is_empty() {
11568 return self.hire_worker_laborer().await;
11569 }
11570 self.workers_toggle_mode_selected().await
11571 }
11572
11573 pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
11576 let row = self
11577 .state
11578 .inventory_selected_row()
11579 .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
11580 .clone();
11581 if row.from != flatland_protocol::InventoryLocation::Root {
11582 anyhow::bail!("select a carried item to give");
11583 }
11584 let Some(instance_id) = row.stack.item_instance_id else {
11585 anyhow::bail!("that stack can't be given");
11586 };
11587 let options = self.nearby_worker_give_targets();
11588 if options.is_empty() {
11589 anyhow::bail!(
11590 "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
11591 );
11592 }
11593 let item_label = row
11594 .stack
11595 .display_name
11596 .as_deref()
11597 .unwrap_or(&row.stack.template_id)
11598 .to_string();
11599 self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
11600 item_instance_id: instance_id,
11601 item_label,
11602 quantity: None,
11603 options,
11604 });
11605 self.state.worker_give_target_picker_index = 0;
11606 self.state.show_worker_give_target_picker = true;
11607 self.state.show_inventory_menu = false;
11609 Ok(())
11610 }
11611
11612 pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
11614 let (px, py, _) = self.state.player_position_with_z();
11615 let mut options: Vec<WorkerGiveTargetOption> = self
11616 .state
11617 .hired_workers
11618 .iter()
11619 .filter_map(|w| {
11620 let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
11621 if dist > WORKER_GIVE_RANGE_M {
11622 return None;
11623 }
11624 Some(WorkerGiveTargetOption {
11625 instance_id: w.instance_id.clone(),
11626 label: w.label.clone(),
11627 distance_m: dist,
11628 })
11629 })
11630 .collect();
11631 options.sort_by(|a, b| {
11632 a.distance_m
11633 .partial_cmp(&b.distance_m)
11634 .unwrap_or(std::cmp::Ordering::Equal)
11635 });
11636 options
11637 }
11638
11639 pub fn close_worker_give_target_picker(&mut self) {
11640 self.state.show_worker_give_target_picker = false;
11641 self.state.worker_give_target_picker = None;
11642 self.state.worker_give_target_picker_index = 0;
11643 }
11644
11645 pub fn worker_give_target_picker_move(&mut self, delta: i32) {
11646 let Some(picker) = &self.state.worker_give_target_picker else {
11647 return;
11648 };
11649 let n = picker.options.len();
11650 if n == 0 {
11651 return;
11652 }
11653 let idx = self.state.worker_give_target_picker_index as i32;
11654 self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11655 }
11656
11657 pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
11658 let Some(picker) = self.state.worker_give_target_picker.clone() else {
11659 anyhow::bail!("give target picker not open");
11660 };
11661 let Some(opt) = picker
11662 .options
11663 .get(self.state.worker_give_target_picker_index)
11664 .cloned()
11665 else {
11666 anyhow::bail!("no worker selected");
11667 };
11668 let Some(worker) = self
11669 .state
11670 .hired_workers
11671 .iter()
11672 .find(|w| w.instance_id == opt.instance_id)
11673 .cloned()
11674 else {
11675 self.close_worker_give_target_picker();
11676 anyhow::bail!("worker no longer hired");
11677 };
11678 self.give_item_to_worker(
11679 &worker.instance_id,
11680 &worker.label,
11681 worker.x,
11682 worker.y,
11683 picker.item_instance_id,
11684 &picker.item_label,
11685 picker.quantity,
11686 )
11687 .await?;
11688 self.close_worker_give_target_picker();
11689 Ok(())
11690 }
11691
11692 pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
11694 self.open_worker_give_target_picker()
11695 }
11696
11697 pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
11699 let Some(worker) = self
11700 .state
11701 .hired_workers
11702 .get(self.state.workers_menu_index)
11703 .cloned()
11704 else {
11705 anyhow::bail!("select a hired worker first");
11706 };
11707 let (px, py, _) = self.state.player_position_with_z();
11708 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11709 if dist > WORKER_GIVE_RANGE_M {
11710 anyhow::bail!(
11711 "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
11712 worker.label
11713 );
11714 }
11715 let options = self.state.giveable_inventory_options();
11716 if options.is_empty() {
11717 anyhow::bail!("nothing in inventory to give");
11718 }
11719 self.state.worker_give_picker = Some(WorkerGivePicker {
11720 worker_instance_id: worker.instance_id,
11721 worker_label: worker.label,
11722 options,
11723 });
11724 self.state.worker_give_picker_index = 0;
11725 self.state.show_worker_give_picker = true;
11726 Ok(())
11727 }
11728
11729 pub fn close_worker_give_picker(&mut self) {
11730 self.state.show_worker_give_picker = false;
11731 self.state.worker_give_picker = None;
11732 self.state.worker_give_picker_index = 0;
11733 }
11734
11735 pub fn worker_give_picker_move(&mut self, delta: i32) {
11736 let Some(picker) = &self.state.worker_give_picker else {
11737 return;
11738 };
11739 let n = picker.options.len();
11740 if n == 0 {
11741 return;
11742 }
11743 let idx = self.state.worker_give_picker_index as i32;
11744 self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11745 }
11746
11747 pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
11749 let Some(picker) = self.state.worker_give_picker.clone() else {
11750 anyhow::bail!("give picker not open");
11751 };
11752 let Some(opt) = picker
11753 .options
11754 .get(self.state.worker_give_picker_index)
11755 .cloned()
11756 else {
11757 anyhow::bail!("no item selected");
11758 };
11759 let Some(worker) = self
11760 .state
11761 .hired_workers
11762 .iter()
11763 .find(|w| w.instance_id == picker.worker_instance_id)
11764 .cloned()
11765 else {
11766 self.close_worker_give_picker();
11767 anyhow::bail!("worker no longer hired");
11768 };
11769 self.give_item_to_worker(
11770 &worker.instance_id,
11771 &worker.label,
11772 worker.x,
11773 worker.y,
11774 opt.item_instance_id,
11775 &opt.label,
11776 None,
11777 )
11778 .await?;
11779 let options = self.state.giveable_inventory_options();
11781 if options.is_empty() {
11782 self.close_worker_give_picker();
11783 } else {
11784 self.state.worker_give_picker = Some(WorkerGivePicker {
11785 worker_instance_id: picker.worker_instance_id,
11786 worker_label: picker.worker_label,
11787 options,
11788 });
11789 if self.state.worker_give_picker_index
11790 >= self
11791 .state
11792 .worker_give_picker
11793 .as_ref()
11794 .map(|p| p.options.len())
11795 .unwrap_or(0)
11796 {
11797 self.state.worker_give_picker_index = self
11798 .state
11799 .worker_give_picker
11800 .as_ref()
11801 .map(|p| p.options.len().saturating_sub(1))
11802 .unwrap_or(0);
11803 }
11804 }
11805 Ok(())
11806 }
11807
11808 pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
11810 let Some(worker) = self
11811 .state
11812 .hired_workers
11813 .get(self.state.workers_menu_index)
11814 .cloned()
11815 else {
11816 anyhow::bail!("select a hired worker first");
11817 };
11818 let (px, py, _) = self.state.player_position_with_z();
11819 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11820 if dist > WORKER_GIVE_RANGE_M {
11821 anyhow::bail!(
11822 "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
11823 worker.label
11824 );
11825 }
11826 let options = self.state.teachable_blueprint_options(&worker);
11827 if options.is_empty() {
11828 anyhow::bail!("no recipes you know that {} still needs", worker.label);
11829 }
11830 self.state.worker_teach_picker = Some(WorkerTeachPicker {
11831 worker_instance_id: worker.instance_id,
11832 worker_label: worker.label,
11833 worker_level: worker.level,
11834 options,
11835 });
11836 self.state.worker_teach_picker_index = 0;
11837 self.state.show_worker_teach_picker = true;
11838 Ok(())
11839 }
11840
11841 pub fn close_worker_teach_picker(&mut self) {
11842 self.state.show_worker_teach_picker = false;
11843 self.state.worker_teach_picker = None;
11844 self.state.worker_teach_picker_index = 0;
11845 }
11846
11847 pub fn worker_teach_picker_move(&mut self, delta: i32) {
11848 let Some(picker) = &self.state.worker_teach_picker else {
11849 return;
11850 };
11851 let n = picker.options.len();
11852 if n == 0 {
11853 return;
11854 }
11855 let idx = self.state.worker_teach_picker_index as i32;
11856 self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11857 }
11858
11859 pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
11860 let Some(picker) = self.state.worker_teach_picker.clone() else {
11861 anyhow::bail!("teach picker not open");
11862 };
11863 let Some(opt) = picker
11864 .options
11865 .get(self.state.worker_teach_picker_index)
11866 .cloned()
11867 else {
11868 anyhow::bail!("nothing selected");
11869 };
11870 if !opt.level_ok {
11871 anyhow::bail!(
11872 "{} needs level {} (is level {})",
11873 picker.worker_label,
11874 opt.min_level,
11875 opt.worker_level
11876 );
11877 }
11878 if !opt.can_afford {
11879 anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
11880 }
11881 let Some(worker) = self
11882 .state
11883 .hired_workers
11884 .iter()
11885 .find(|w| w.instance_id == picker.worker_instance_id)
11886 .cloned()
11887 else {
11888 anyhow::bail!("worker gone");
11889 };
11890 let (px, py, _) = self.state.player_position_with_z();
11891 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11892 if dist > WORKER_GIVE_RANGE_M {
11893 anyhow::bail!("worker {} too far — stand next to them", worker.label);
11894 }
11895 self.seq += 1;
11896 self.session
11897 .submit_intent(Intent::TeachWorkerBlueprint {
11898 entity_id: self.state.entity_id,
11899 worker_instance_id: picker.worker_instance_id.clone(),
11900 blueprint_id: opt.blueprint_id.clone(),
11901 seq: self.seq,
11902 })
11903 .await?;
11904 self.state.intents_sent += 1;
11905 self.state.push_log(format!(
11906 "Teaching {} to {} ({} cp)",
11907 opt.label, picker.worker_label, opt.cost_copper
11908 ));
11909 self.close_worker_teach_picker();
11910 Ok(())
11911 }
11912
11913 async fn give_item_to_worker(
11914 &mut self,
11915 worker_instance_id: &str,
11916 worker_label: &str,
11917 worker_x: f32,
11918 worker_y: f32,
11919 item_instance_id: uuid::Uuid,
11920 item_label: &str,
11921 quantity: Option<u32>,
11922 ) -> anyhow::Result<()> {
11923 let (px, py, _) = self.state.player_position_with_z();
11924 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
11925 if dist > WORKER_GIVE_RANGE_M {
11926 anyhow::bail!("worker {worker_label} too far — stand next to them");
11927 }
11928 self.seq += 1;
11929 self.session
11930 .submit_intent(Intent::GiveWorkerItem {
11931 entity_id: self.state.entity_id,
11932 worker_instance_id: worker_instance_id.to_string(),
11933 item_instance_id,
11934 quantity,
11935 seq: self.seq,
11936 })
11937 .await?;
11938 self.state.intents_sent += 1;
11939 self.state
11940 .remove_carried_instance(item_instance_id, quantity);
11941 self.state
11942 .push_log(format!("Gave {item_label} to {worker_label}"));
11943 Ok(())
11944 }
11945
11946 pub async fn equip_item_on_worker(
11950 &mut self,
11951 worker_instance_id: &str,
11952 item_instance_id: uuid::Uuid,
11953 slot: &str,
11954 ) -> anyhow::Result<()> {
11955 let Some(worker) = self
11956 .state
11957 .hired_workers
11958 .iter()
11959 .find(|worker| worker.instance_id == worker_instance_id)
11960 .cloned()
11961 else {
11962 anyhow::bail!("worker not found");
11963 };
11964 let (px, py, _) = self.state.player_position_with_z();
11965 if (worker.x - px).hypot(worker.y - py) > WORKER_GIVE_RANGE_M {
11966 anyhow::bail!("worker {} too far — stand next to them", worker.label);
11967 }
11968 self.seq += 1;
11969 self.session
11970 .submit_intent(Intent::EquipWorkerItem {
11971 entity_id: self.state.entity_id,
11972 worker_instance_id: worker.instance_id.clone(),
11973 item_instance_id,
11974 slot: slot.to_string(),
11975 seq: self.seq,
11976 })
11977 .await?;
11978 self.state.intents_sent += 1;
11979 self.state
11980 .push_log(format!("Equipped {slot} on {}", worker.label));
11981 Ok(())
11982 }
11983
11984 pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
11986 let Some(worker) = self
11987 .state
11988 .hired_workers
11989 .get(self.state.workers_menu_index)
11990 .cloned()
11991 else {
11992 anyhow::bail!("select a hired worker first");
11993 };
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!(
11998 "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
11999 worker.label
12000 );
12001 }
12002 let options = Self::worker_inventory_options(&worker);
12003 if options.is_empty() {
12004 anyhow::bail!("{} isn't carrying anything", worker.label);
12005 }
12006 let initial_qty = options
12007 .first()
12008 .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
12009 .unwrap_or(1);
12010 self.state.worker_take_picker = Some(WorkerTakePicker {
12011 worker_instance_id: worker.instance_id,
12012 worker_label: worker.label,
12013 options,
12014 quantity: initial_qty,
12015 });
12016 self.state.worker_take_picker_index = 0;
12017 self.state.show_worker_take_picker = true;
12018 Ok(())
12019 }
12020
12021 fn worker_inventory_options(
12022 worker: &flatland_protocol::HiredWorkerView,
12023 ) -> Vec<WorkerGiveOption> {
12024 worker
12025 .inventory
12026 .iter()
12027 .filter_map(|stack| {
12028 let item_instance_id = stack.item_instance_id?;
12029 let label = stack
12030 .display_name
12031 .clone()
12032 .unwrap_or_else(|| stack.template_id.clone());
12033 let label = if stack.quantity > 1 {
12034 format!("{label} ×{}", stack.quantity)
12035 } else {
12036 label
12037 };
12038 Some(WorkerGiveOption {
12039 item_instance_id,
12040 label,
12041 quantity: stack.quantity,
12042 template_id: stack.template_id.clone(),
12043 })
12044 })
12045 .collect()
12046 }
12047
12048 pub fn close_worker_take_picker(&mut self) {
12049 self.state.show_worker_take_picker = false;
12050 self.state.worker_take_picker = None;
12051 self.state.worker_take_picker_index = 0;
12052 }
12053
12054 pub fn worker_take_picker_move(&mut self, delta: i32) {
12055 let Some(picker) = &self.state.worker_take_picker else {
12056 return;
12057 };
12058 let n = picker.options.len();
12059 if n == 0 {
12060 return;
12061 }
12062 let idx = self.state.worker_take_picker_index as i32;
12063 self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
12064 self.clamp_worker_take_quantity();
12065 }
12066
12067 pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
12068 let Some(picker) = &mut self.state.worker_take_picker else {
12069 return;
12070 };
12071 let max = picker
12072 .options
12073 .get(self.state.worker_take_picker_index)
12074 .map(|o| o.quantity.max(1))
12075 .unwrap_or(1);
12076 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
12077 picker.quantity = next as u32;
12078 }
12079
12080 pub fn worker_take_picker_set_quantity_max(&mut self) {
12081 let Some(picker) = &mut self.state.worker_take_picker else {
12082 return;
12083 };
12084 let max = picker
12085 .options
12086 .get(self.state.worker_take_picker_index)
12087 .map(|o| o.quantity.max(1))
12088 .unwrap_or(1);
12089 picker.quantity = max;
12090 }
12091
12092 pub fn worker_take_picker_set_quantity_min(&mut self) {
12093 let Some(picker) = &mut self.state.worker_take_picker else {
12094 return;
12095 };
12096 picker.quantity = 1;
12097 self.clamp_worker_take_quantity();
12098 }
12099
12100 fn clamp_worker_take_quantity(&mut self) {
12101 let Some(picker) = &mut self.state.worker_take_picker else {
12102 return;
12103 };
12104 let max = picker
12105 .options
12106 .get(self.state.worker_take_picker_index)
12107 .map(|o| o.quantity.max(1))
12108 .unwrap_or(1);
12109 if picker.quantity == 0 || picker.quantity > max {
12110 picker.quantity = if max > 1 { 1 } else { max };
12111 }
12112 }
12113
12114 pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
12115 let Some(picker) = self.state.worker_take_picker.clone() else {
12116 anyhow::bail!("take picker not open");
12117 };
12118 let Some(opt) = picker
12119 .options
12120 .get(self.state.worker_take_picker_index)
12121 .cloned()
12122 else {
12123 anyhow::bail!("no item selected");
12124 };
12125 let Some(worker) = self
12126 .state
12127 .hired_workers
12128 .iter()
12129 .find(|w| w.instance_id == picker.worker_instance_id)
12130 .cloned()
12131 else {
12132 self.close_worker_take_picker();
12133 anyhow::bail!("worker no longer hired");
12134 };
12135 let qty = picker.quantity.clamp(1, opt.quantity.max(1));
12136 let intent_qty = if qty >= opt.quantity { None } else { Some(qty) };
12137 self.take_item_from_worker(
12138 &worker.instance_id,
12139 &worker.label,
12140 worker.x,
12141 worker.y,
12142 opt.item_instance_id,
12143 &opt.label,
12144 intent_qty,
12145 )
12146 .await?;
12147 Ok(())
12150 }
12151
12152 async fn take_item_from_worker(
12153 &mut self,
12154 worker_instance_id: &str,
12155 worker_label: &str,
12156 worker_x: f32,
12157 worker_y: f32,
12158 item_instance_id: uuid::Uuid,
12159 item_label: &str,
12160 quantity: Option<u32>,
12161 ) -> anyhow::Result<()> {
12162 let (px, py, _) = self.state.player_position_with_z();
12163 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
12164 if dist > WORKER_GIVE_RANGE_M {
12165 anyhow::bail!("worker {worker_label} too far — stand next to them");
12166 }
12167 self.seq += 1;
12168 self.session
12169 .submit_intent(Intent::TakeWorkerItem {
12170 entity_id: self.state.entity_id,
12171 worker_instance_id: worker_instance_id.to_string(),
12172 item_instance_id,
12173 quantity,
12174 seq: self.seq,
12175 })
12176 .await?;
12177 self.state.intents_sent += 1;
12178 let qty_note = quantity.map(|q| format!(" ×{q}")).unwrap_or_default();
12179 self.state.push_log(format!(
12180 "Taking {item_label}{qty_note} from {worker_label}…"
12181 ));
12182 Ok(())
12183 }
12184
12185 pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
12186 if let Some(since) = self.state.pending_worker_hire_since {
12187 if since.elapsed() < WORKER_HIRE_PENDING_TIMEOUT {
12188 anyhow::bail!("hire request still pending — wait for the worker roster update");
12189 }
12190 self.state.pending_worker_hire_since = None;
12191 }
12192 if !self.state.has_worker_lodging() {
12193 anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
12194 }
12195 self.seq += 1;
12196 self.session
12197 .submit_intent(Intent::HireWorker {
12198 entity_id: self.state.entity_id,
12199 def_id: "worker_laborer".into(),
12200 wage_copper_per_interval: 8,
12201 lodging_container_id: None,
12202 job_yaml: None,
12203 seq: self.seq,
12204 })
12205 .await?;
12206 self.state.intents_sent += 1;
12207 self.state.pending_worker_hire_since = Some(Instant::now());
12208 Ok(())
12209 }
12210
12211 pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
12212 let Some(worker) = self
12213 .state
12214 .hired_workers
12215 .get(self.state.workers_menu_index)
12216 .cloned()
12217 else {
12218 anyhow::bail!("select a hired worker first");
12219 };
12220 let lodging = worker.lodging_container_id.clone().or_else(|| {
12221 crate::worker_route_editor::owned_lodging_container_ids(
12222 &self.state.placed_containers,
12223 self.state.character_id,
12224 )
12225 .into_iter()
12226 .next()
12227 .map(|(id, _)| id)
12228 });
12229 let label = worker.label.clone();
12230 let editor = if let Some(route) = &worker.route {
12231 crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
12232 worker.instance_id,
12233 worker.label,
12234 route,
12235 lodging,
12236 )
12237 } else {
12238 crate::worker_route_editor::WorkerRouteEditorState::new(
12239 worker.instance_id,
12240 worker.label,
12241 lodging,
12242 )
12243 };
12244 self.state.worker_route_editor = Some(editor);
12245 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12246 if let Some(collapsed) =
12247 crate::client_config::ClientConfig::load().worker_route_panel_collapsed
12248 {
12249 ed.panel_collapsed = collapsed;
12250 }
12251 }
12252 self.state.show_workers_menu = false;
12253 self.state.push_log(format!(
12254 "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
12255 ));
12256 Ok(())
12257 }
12258
12259 pub fn close_worker_route_editor(&mut self) {
12260 self.state.worker_route_editor = None;
12261 }
12262
12263 pub fn worker_route_editor_toggle_panel(&mut self) {
12264 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12265 ed.toggle_panel_collapsed();
12266 let collapsed = ed.panel_collapsed;
12267 let mut cfg = crate::client_config::ClientConfig::load();
12268 let _ = cfg.save_worker_route_panel_collapsed(collapsed);
12269 }
12270 }
12271
12272 pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
12273 let n = {
12274 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12275 return;
12276 };
12277 ed.append_waypoint(x, y, z);
12278 ed.stop_count()
12279 };
12280 self.state
12281 .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
12282 }
12283
12284 fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
12287 let (px, py, _) = self.state.player_position_with_z();
12288 let inside = self.state.effective_inside_building();
12289 crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
12290 &self.state.placed_containers,
12291 &self.state.buildings,
12292 self.state.character_id,
12293 px,
12294 py,
12295 &self.state.hired_workers,
12296 inside.as_deref(),
12297 )
12298 }
12299
12300 fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
12301 self.state.route_editor_node_candidates()
12302 }
12303
12304 fn re_open_harvest_picker(&mut self, index: usize, picked: std::collections::BTreeSet<String>) {
12305 use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
12306 let nodes = self.state.route_editor_node_candidates();
12307 let index = if nodes.is_empty() {
12308 ROUTE_PICKER_DONE_ROW
12309 } else {
12310 index.max(1).min(nodes.len())
12311 };
12312 self.re_open_sheet(S::HarvestPicker {
12313 index,
12314 picked,
12315 nodes,
12316 });
12317 }
12318
12319 fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
12320 let (px, py, _) = self.state.player_position_with_z();
12321 let templates = self.re_template_candidates();
12322 crate::worker_route_editor::trade_npc_candidates(
12323 &self.state.npcs,
12324 px,
12325 py,
12326 &templates,
12327 )
12328 }
12329
12330 fn re_template_candidates(&self) -> Vec<String> {
12331 let mut extra = Vec::new();
12332 if let Some(ed) = self.state.worker_route_editor.as_ref() {
12333 for stop in &ed.stops {
12334 match stop {
12335 crate::worker_route_editor::WorkerRouteStop::DepositAt {
12336 filter: Some(filter),
12337 ..
12338 } => extra.extend(filter.iter().cloned()),
12339 crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. }
12340 | crate::worker_route_editor::WorkerRouteStop::ListOnMarket {
12341 template,
12342 ..
12343 } => {
12344 extra.push(template.clone());
12345 }
12346 crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
12347 if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint)
12348 {
12349 extra.push(bp.output.clone());
12350 for input in &bp.inputs {
12351 extra.push(input.template_id.clone());
12352 }
12353 }
12354 }
12355 crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
12356 for it in items {
12357 extra.push(it.template.clone());
12358 }
12359 }
12360 _ => {}
12361 }
12362 }
12363 if let Some(worker) = self
12365 .state
12366 .hired_workers
12367 .iter()
12368 .find(|w| w.instance_id == ed.worker_instance_id)
12369 {
12370 for recipe in &worker.known_blueprint_ids {
12371 if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
12372 extra.push(bp.output.clone());
12373 }
12374 }
12375 for stack in &worker.inventory {
12376 if !stack.template_id.is_empty() && stack.quantity > 0 {
12377 extra.push(stack.template_id.clone());
12378 }
12379 }
12380 }
12381 }
12382 crate::worker_route_editor::route_item_template_candidates(
12383 &self.state.placed_containers,
12384 self.state.character_id,
12385 &self.state.inventory,
12386 &self.state.blueprints,
12387 if self.state.harvest_route_nodes.is_empty() {
12388 &self.state.resource_nodes
12389 } else {
12390 &self.state.harvest_route_nodes
12391 },
12392 &extra,
12393 Some(&self.state.item_catalog),
12394 )
12395 }
12396
12397 fn re_blueprint_ids(&self) -> Vec<String> {
12398 let worker_known: Option<&[String]> = self
12399 .state
12400 .worker_route_editor
12401 .as_ref()
12402 .and_then(|ed| {
12403 self.state
12404 .hired_workers
12405 .iter()
12406 .find(|w| w.instance_id == ed.worker_instance_id)
12407 })
12408 .map(|w| w.known_blueprint_ids.as_slice());
12409 crate::worker_route_editor::worker_craft_blueprint_ids(&self.state.blueprints, worker_known)
12410 }
12411
12412 fn re_bed_candidates(&self) -> Vec<(String, String)> {
12413 crate::worker_route_editor::owned_lodging_container_ids(
12414 &self.state.placed_containers,
12415 self.state.character_id,
12416 )
12417 }
12418
12419 fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
12420 self.state
12421 .placed_containers
12422 .iter()
12423 .find(|c| c.id == container_id)
12424 .map(|c| c.contents.clone())
12425 .unwrap_or_default()
12426 }
12427
12428 fn re_sheet_supports_filter(&self) -> bool {
12431 use crate::worker_route_editor::RouteEditorSheet as S;
12432 self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12433 matches!(
12434 ed.sheet,
12435 S::HarvestPicker { .. }
12436 | S::SellItem { .. }
12437 | S::MarketListItem { .. }
12438 | S::DepositFilter { .. }
12439 | S::WithdrawItems { .. }
12440 | S::WithdrawContainers { .. }
12441 | S::DepositContainers { .. }
12442 | S::SellNpcs { .. }
12443 | S::CraftBlueprint { .. }
12444 | S::BedPicker { .. }
12445 )
12446 })
12447 }
12448
12449 pub fn re_sheet_row_visible(&self, row: usize) -> bool {
12451 use crate::worker_route_editor::{
12452 harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
12453 ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
12454 };
12455 let Some(ed) = self.state.worker_route_editor.as_ref() else {
12456 return false;
12457 };
12458 let filter = &ed.sheet_filter;
12459 match &ed.sheet {
12460 S::HarvestPicker { nodes, .. } => harvest_picker_row_matches(nodes, row, filter),
12461 S::SellItem { templates, .. } | S::MarketListItem { templates, .. } => {
12462 if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
12463 return true;
12464 }
12465 let slot = row.saturating_sub(2);
12466 templates.get(slot).is_some_and(|t| {
12467 let label = self.state.template_display_name(t);
12468 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
12469 })
12470 }
12471 S::DepositFilter { rows, .. } => {
12472 if row >= rows.len() {
12473 return true;
12474 }
12475 rows.get(row).is_some_and(|(t, _)| {
12476 let label = self.state.template_display_name(t);
12477 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
12478 })
12479 }
12480 S::WithdrawItems { lines, .. } => {
12481 if row >= lines.len() {
12482 return true;
12483 }
12484 lines.get(row).is_some_and(|l| {
12485 let label = self.state.template_display_name(&l.template);
12486 list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
12487 })
12488 }
12489 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
12490 self.re_container_candidates().get(row).is_some_and(|c| {
12491 list_filter_row_matches(
12492 filter,
12493 Some(c.dist),
12494 &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
12495 )
12496 })
12497 }
12498 S::SellNpcs { .. } => {
12499 if row == 0 {
12500 return true;
12501 }
12502 self.re_npc_candidates().get(row - 1).is_some_and(|n| {
12503 list_filter_row_matches(
12504 filter,
12505 Some(n.dist),
12506 &[n.label.as_str(), n.id.as_str()],
12507 )
12508 })
12509 }
12510 S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
12511 let label = self
12512 .state
12513 .blueprints
12514 .iter()
12515 .find(|b| &b.id == id)
12516 .map(|b| {
12517 if b.label.is_empty() {
12518 id.as_str()
12519 } else {
12520 b.label.as_str()
12521 }
12522 })
12523 .unwrap_or(id.as_str());
12524 list_filter_row_matches(filter, None, &[id.as_str(), label])
12525 }),
12526 S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
12527 list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
12528 }),
12529 _ => true,
12530 }
12531 }
12532
12533 fn re_sheet_clamp_index(&mut self) {
12534 let count = self.re_sheet_row_count();
12535 if count == 0 {
12536 return;
12537 }
12538 let cur = self.re_sheet_index();
12539 if self.re_sheet_row_visible(cur) {
12540 return;
12541 }
12542 for offset in 1..count {
12543 if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
12544 self.re_sheet_set_index(cur + offset);
12545 return;
12546 }
12547 if cur >= offset && self.re_sheet_row_visible(cur - offset) {
12548 self.re_sheet_set_index(cur - offset);
12549 return;
12550 }
12551 }
12552 }
12553
12554 fn re_sheet_set_index(&mut self, index: usize) {
12555 use crate::worker_route_editor::RouteEditorSheet as S;
12556 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12557 return;
12558 };
12559 match &mut ed.sheet {
12560 S::AddMenu { index: slot }
12561 | S::WaypointMenu { index: slot }
12562 | S::HarvestPicker { index: slot, .. }
12563 | S::WithdrawContainers { index: slot }
12564 | S::DepositContainers { index: slot }
12565 | S::SellNpcs { index: slot }
12566 | S::CraftBlueprint { index: slot }
12567 | S::BedPicker { index: slot }
12568 | S::FarmPlotPicker { index: slot, .. }
12569 | S::FarmPlantSeed { index: slot, .. }
12570 | S::WithdrawItems { index: slot, .. }
12571 | S::DepositFilter { index: slot, .. }
12572 | S::SellItem { index: slot, .. } | S::MarketListItem { index: slot, .. } => *slot = index,
12573 _ => {}
12574 }
12575 }
12576
12577 pub fn re_focus_sheet_filter(&mut self) {
12578 if !self.re_sheet_supports_filter() {
12579 return;
12580 }
12581 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12582 ed.sheet_filter_focused = true;
12583 }
12584 }
12585
12586 pub fn re_blur_sheet_filter_keep_text(&mut self) {
12587 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12588 return;
12589 };
12590 if !ed.sheet_filter_focused {
12591 return;
12592 }
12593 ed.sheet_filter_focused = false;
12594 self.re_sheet_clamp_index();
12595 }
12596
12597 pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
12598 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12599 return false;
12600 };
12601 if ed.sheet_filter_focused {
12602 ed.sheet_filter_focused = false;
12603 self.re_sheet_clamp_index();
12604 return true;
12605 }
12606 if !ed.sheet_filter.is_empty() {
12607 ed.sheet_filter.clear();
12608 self.re_sheet_clamp_index();
12609 return true;
12610 }
12611 false
12612 }
12613
12614 pub fn re_append_sheet_filter_char(&mut self, ch: char) {
12615 if ch.is_control() {
12616 return;
12617 }
12618 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12619 return;
12620 };
12621 if !ed.sheet_filter_focused {
12622 return;
12623 }
12624 ed.sheet_filter.push(ch);
12625 self.re_sheet_set_index(0);
12626 self.re_sheet_clamp_index();
12627 }
12628
12629 pub fn re_sheet_filter_backspace(&mut self) {
12630 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12631 return;
12632 };
12633 if !ed.sheet_filter_focused {
12634 return;
12635 }
12636 ed.sheet_filter.pop();
12637 self.re_sheet_set_index(0);
12638 self.re_sheet_clamp_index();
12639 }
12640
12641 pub fn re_sheet_row_count(&self) -> usize {
12643 use crate::worker_route_editor::{
12644 harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
12645 };
12646 let Some(ed) = self.state.worker_route_editor.as_ref() else {
12647 return 0;
12648 };
12649 match &ed.sheet {
12650 S::Stops => ed.stops.len(),
12651 S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
12652 S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
12653 S::WaypointMapPick => 0,
12654 S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
12655 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
12656 self.re_container_candidates().len()
12657 }
12658 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, .. } => {
12662 sell_item_picker_row_count(templates.len())
12663 }
12664 S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
12665 S::WaitEntry { .. } => 1,
12666 S::BedPicker { .. } => self.re_bed_candidates().len(),
12667 S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
12668 S::FarmPlantSeed { seeds, .. } => seeds.len(),
12669 }
12670 }
12671
12672 pub fn re_sheet_index(&self) -> usize {
12674 use crate::worker_route_editor::RouteEditorSheet as S;
12675 let Some(ed) = self.state.worker_route_editor.as_ref() else {
12676 return 0;
12677 };
12678 match &ed.sheet {
12679 S::AddMenu { index }
12680 | S::WaypointMenu { index }
12681 | S::HarvestPicker { index, .. }
12682 | S::WithdrawContainers { index }
12683 | S::DepositContainers { index }
12684 | S::SellNpcs { index }
12685 | S::CraftBlueprint { index }
12686 | S::BedPicker { index }
12687 | S::FarmPlotPicker { index, .. }
12688 | S::FarmPlantSeed { index, .. }
12689 | S::WithdrawItems { index, .. }
12690 | S::DepositFilter { index, .. }
12691 | S::SellItem { index, .. } | S::MarketListItem { index, .. } => *index,
12692 _ => 0,
12693 }
12694 }
12695
12696 pub fn re_sheet_move(&mut self, delta: i32) {
12698 let count = self.re_sheet_row_count();
12699 if count == 0 {
12700 return;
12701 }
12702 let cur = self.re_sheet_index();
12703 let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
12704 self.re_sheet_set_index(next);
12705 }
12706
12707 pub fn re_sheet_page(&mut self, pages: i32) {
12708 let count = self.re_sheet_row_count();
12709 if count == 0 {
12710 return;
12711 }
12712 let cur = self.re_sheet_index();
12713 let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
12714 self.re_sheet_set_index(next);
12715 }
12716
12717 pub fn re_sheet_adjust(&mut self, delta: i32) {
12719 use crate::worker_route_editor::RouteEditorSheet as S;
12720 let index = self.re_sheet_index();
12721 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12722 return;
12723 };
12724 match &mut ed.sheet {
12725 S::WithdrawItems { lines, .. } => {
12726 if let Some(line) = lines.get_mut(index) {
12727 line.adjust_qty(delta);
12728 }
12729 }
12730 S::WaitEntry { ticks } => {
12731 *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
12732 }
12733 _ => {}
12734 }
12735 }
12736
12737 pub fn re_sheet_back(&mut self) {
12738 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12739 return;
12740 };
12741 use crate::worker_route_editor::RouteEditorSheet as S;
12742 let was_editing = ed.editing_index.is_some();
12743 let from_top_picker = matches!(
12744 ed.sheet,
12745 S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
12746 );
12747 ed.sheet_back();
12748 if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
12749 self.state
12751 .push_log("Route: left edit sheet — press s to save current stops".to_string());
12752 }
12753 }
12754
12755 pub fn re_at_root_sheet(&self) -> bool {
12757 self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12758 matches!(
12759 ed.sheet,
12760 crate::worker_route_editor::RouteEditorSheet::Stops
12761 )
12762 })
12763 }
12764
12765 pub fn re_open_add_menu(&mut self) {
12766 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12767 ed.open_add_menu();
12768 }
12769 }
12770
12771 pub fn re_open_bed_picker(&mut self) {
12772 let beds = self.re_bed_candidates();
12773 if beds.is_empty() {
12774 self.state
12775 .push_log("Route: place a camp bed first".to_string());
12776 return;
12777 }
12778 let current = self
12779 .state
12780 .worker_route_editor
12781 .as_ref()
12782 .and_then(|ed| ed.lodging_container_id.clone());
12783 let index = current
12784 .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
12785 .unwrap_or(0);
12786 self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
12787 }
12788
12789 fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
12790 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12791 ed.open_sheet(sheet);
12792 }
12793 }
12794
12795 fn re_confirm_stop(&mut self, stop: crate::worker_route_editor::WorkerRouteStop, what: String) {
12797 let appended = self
12798 .state
12799 .worker_route_editor
12800 .as_mut()
12801 .is_some_and(|ed| ed.confirm_stop(stop));
12802 if appended {
12803 self.state.push_log(format!("Route: + {what}"));
12804 } else {
12805 self.state
12806 .push_log(format!("Route: {what} already in route — selected it"));
12807 }
12808 }
12809
12810 fn re_open_withdraw_items(&mut self, container_id: String) {
12811 use crate::worker_route_editor::{
12812 RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
12813 };
12814 let contents = self.re_container_contents(&container_id);
12815 let existing = self
12819 .state
12820 .worker_route_editor
12821 .as_ref()
12822 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12823 .and_then(|stop| match stop {
12824 WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
12825 _ => None,
12826 })
12827 .unwrap_or_default();
12828 let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
12829 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12832 let _ = ed.retarget_withdraw_container(container_id.clone());
12833 }
12834 self.re_open_sheet(S::WithdrawItems {
12835 container_id,
12836 lines,
12837 index: 0,
12838 });
12839 }
12840
12841 fn re_withdraw_items_activate(&mut self, index: usize) {
12842 use crate::worker_route_editor::{
12843 RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
12844 };
12845 enum Outcome {
12846 Cycled,
12847 Confirmed(String),
12848 Empty,
12849 }
12850 let outcome = {
12851 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12852 return;
12853 };
12854 let S::WithdrawItems {
12855 container_id,
12856 lines,
12857 index: sheet_index,
12858 } = &mut ed.sheet
12859 else {
12860 return;
12861 };
12862 *sheet_index = index;
12863 if index < lines.len() {
12864 lines[index].cycle();
12865 Outcome::Cycled
12866 } else {
12867 let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
12868 if items.is_empty() {
12869 Outcome::Empty
12870 } else {
12871 let stop = WorkerRouteStop::WithdrawFrom {
12872 container_id: container_id.clone(),
12873 items,
12874 };
12875 let summary = stop.summary();
12876 ed.confirm_stop(stop);
12877 Outcome::Confirmed(summary)
12878 }
12879 }
12880 };
12881 match outcome {
12882 Outcome::Cycled => {}
12883 Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
12884 Outcome::Empty => self.state.push_log(
12885 "Route: pick at least one item (Space/Enter toggles All/qty)".to_string(),
12886 ),
12887 }
12888 }
12889
12890 fn re_open_deposit_filter(&mut self, container_id: String) {
12891 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12892 let existing_filter = self
12894 .state
12895 .worker_route_editor
12896 .as_ref()
12897 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12898 .and_then(|stop| match stop {
12899 WorkerRouteStop::DepositAt { filter, .. } => {
12900 Some(filter.clone().unwrap_or_default())
12901 }
12902 _ => None,
12903 });
12904 let mut candidates = self.re_template_candidates();
12905 if let Some(ref chosen) = existing_filter {
12906 for t in chosen {
12907 if !candidates.iter().any(|c| c == t) {
12908 candidates.push(t.clone());
12909 }
12910 }
12911 candidates.sort();
12912 candidates.dedup();
12913 }
12914 let rows: Vec<(String, bool)> = match existing_filter {
12915 Some(chosen) => candidates
12916 .iter()
12917 .map(|t| (t.clone(), chosen.contains(t)))
12918 .collect(),
12919 None => candidates.into_iter().map(|t| (t, false)).collect(),
12920 };
12921 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12922 let _ = ed.retarget_deposit_container(container_id.clone());
12923 }
12924 self.re_open_sheet(S::DepositFilter {
12925 container_id,
12926 rows,
12927 index: 0,
12928 });
12929 }
12930
12931 fn re_deposit_filter_activate(&mut self, index: usize) {
12932 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12933 let mut confirmed: Option<String> = None;
12934 {
12935 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12936 return;
12937 };
12938 let S::DepositFilter {
12939 container_id,
12940 rows,
12941 index: sheet_index,
12942 } = &mut ed.sheet
12943 else {
12944 return;
12945 };
12946 *sheet_index = index;
12947 if index < rows.len() {
12948 rows[index].1 = !rows[index].1;
12949 } else {
12950 let chosen: Vec<String> = rows
12952 .iter()
12953 .filter(|(_, on)| *on)
12954 .map(|(t, _)| t.clone())
12955 .collect();
12956 let filter = if chosen.is_empty() {
12957 None
12958 } else {
12959 Some(chosen)
12960 };
12961 let stop = WorkerRouteStop::DepositAt {
12962 container_id: container_id.clone(),
12963 filter,
12964 };
12965 confirmed = Some(stop.summary());
12966 ed.confirm_stop(stop);
12967 }
12968 }
12969 if let Some(what) = confirmed {
12970 self.state.push_log(format!("Route: + {what}"));
12971 }
12972 }
12973
12974 fn re_open_sell_item(&mut self, npc_id: Option<String>) {
12975 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12976 let (pre_npc, pre_template, pre_all) = self
12978 .state
12979 .worker_route_editor
12980 .as_ref()
12981 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12982 .and_then(|stop| match stop {
12983 WorkerRouteStop::TradeWith {
12984 npc_id,
12985 template,
12986 sell_all,
12987 } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
12988 _ => None,
12989 })
12990 .unwrap_or((None, None, true));
12991 let npc_id = npc_id.or(pre_npc);
12992 let mut templates = crate::worker_route_editor::sellable_route_item_template_candidates(
12993 &self.re_template_candidates(),
12994 &self.state.npcs,
12995 npc_id.as_deref(),
12996 );
12997 if let Some(template) = pre_template.as_ref() {
13000 if !templates.iter().any(|candidate| candidate == template) {
13001 templates.push(template.clone());
13002 templates.sort();
13003 }
13004 }
13005 if templates.is_empty() {
13006 let msg = crate::worker_route_editor::sell_merchant_empty_reason(
13007 npc_id.as_deref(),
13008 &self.state.npcs,
13009 &self.re_template_candidates(),
13010 );
13011 self.state.push_log(msg);
13012 return;
13013 }
13014 let mut picked = std::collections::BTreeSet::new();
13015 if let Some(t) = pre_template {
13016 picked.insert(t);
13017 }
13018 self.re_open_sheet(S::SellItem {
13019 npc_id,
13020 templates,
13021 index: if picked.is_empty() {
13022 crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
13023 } else {
13024 2
13025 },
13026 sell_all: pre_all,
13027 picked,
13028 });
13029 }
13030
13031 fn re_sell_item_activate(&mut self, index: usize) {
13032 use crate::worker_route_editor::{
13033 RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
13034 };
13035 let mut batch_log: Option<String> = None;
13036 {
13037 let Some(ed) = self.state.worker_route_editor.as_mut() else {
13038 return;
13039 };
13040 let S::SellItem {
13041 npc_id,
13042 templates,
13043 index: sheet_index,
13044 sell_all,
13045 picked,
13046 } = &mut ed.sheet
13047 else {
13048 return;
13049 };
13050 *sheet_index = index;
13051 if index == ROUTE_PICKER_DONE_ROW {
13052 if picked.is_empty() {
13053 batch_log =
13054 Some("Route: pick at least one item (Space toggles, Done confirms)".into());
13055 } else {
13056 let picks: Vec<String> = picked.iter().cloned().collect();
13057 let npc = npc_id.clone();
13058 let all = *sell_all;
13059 let added = ed.confirm_trade_picks(npc, &picks, all);
13060 batch_log = Some(format!("Route: + {added} sell stop(s)"));
13061 }
13062 } else if index == SELL_ITEM_TOGGLE_ROW {
13063 *sell_all = !*sell_all;
13064 } else if let Some(template) = templates.get(index.saturating_sub(2)) {
13065 let sellable = crate::worker_route_editor::sellable_route_item_template_candidates(
13066 std::slice::from_ref(template),
13067 &self.state.npcs,
13068 npc_id.as_deref(),
13069 )
13070 .iter()
13071 .any(|candidate| candidate == template);
13072 if !sellable && !picked.contains(template) {
13073 return;
13074 }
13075 if picked.contains(template) {
13076 picked.remove(template);
13077 } else {
13078 picked.insert(template.clone());
13079 }
13080 }
13081 }
13082 if let Some(msg) = batch_log {
13083 self.state.push_log(msg);
13084 }
13085 }
13086
13087 fn re_open_market_list_item(&mut self) {
13088 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13089 let (pre_hall, pre_template, pre_all) = self
13090 .state
13091 .worker_route_editor
13092 .as_ref()
13093 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
13094 .and_then(|stop| match stop {
13095 WorkerRouteStop::ListOnMarket {
13096 hall_id,
13097 template,
13098 list_all,
13099 } => Some((hall_id.clone(), Some(template.clone()), *list_all)),
13100 _ => None,
13101 })
13102 .unwrap_or((None, None, true));
13103 let mut templates = self.re_template_candidates();
13104 templates.sort_by_key(|t| {
13107 std::cmp::Reverse(self.state.item_base_value_copper_hint(t).unwrap_or(0))
13108 });
13109 if let Some(template) = pre_template.as_ref() {
13110 if !templates.iter().any(|c| c == template) {
13111 templates.push(template.clone());
13112 }
13113 }
13114 if templates.is_empty() {
13115 self.state.push_log("Route: no item templates available for market list".to_string());
13116 return;
13117 }
13118 let mut picked = std::collections::BTreeSet::new();
13119 if let Some(t) = pre_template {
13120 picked.insert(t);
13121 }
13122 self.re_open_sheet(S::MarketListItem {
13123 hall_id: pre_hall,
13124 templates,
13125 index: if picked.is_empty() {
13126 crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
13127 } else {
13128 2
13129 },
13130 list_all: pre_all,
13131 picked,
13132 });
13133 }
13134
13135 fn re_market_list_item_activate(&mut self, index: usize) {
13136 use crate::worker_route_editor::{
13137 RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
13138 };
13139 let mut batch_log: Option<String> = None;
13140 {
13141 let Some(ed) = self.state.worker_route_editor.as_mut() else {
13142 return;
13143 };
13144 let S::MarketListItem {
13145 hall_id,
13146 templates,
13147 index: sheet_index,
13148 list_all,
13149 picked,
13150 } = &mut ed.sheet
13151 else {
13152 return;
13153 };
13154 *sheet_index = index;
13155 if index == ROUTE_PICKER_DONE_ROW {
13156 if picked.is_empty() {
13157 batch_log = Some(
13158 "Route: pick at least one item (Space toggles, Done confirms)".into(),
13159 );
13160 } else {
13161 let picks: Vec<String> = picked.iter().cloned().collect();
13162 let hall = hall_id.clone();
13163 let all = *list_all;
13164 let added = ed.confirm_market_list_picks(hall, &picks, all);
13165 batch_log = Some(format!("Route: + {added} market-list stop(s)"));
13166 }
13167 } else if index == SELL_ITEM_TOGGLE_ROW {
13168 *list_all = !*list_all;
13169 } else if let Some(template) = templates.get(index.saturating_sub(2)) {
13170 if picked.contains(template) {
13171 picked.remove(template);
13172 } else {
13173 picked.insert(template.clone());
13174 }
13175 }
13176 }
13177 if let Some(msg) = batch_log {
13178 self.state.push_log(msg);
13179 }
13180 }
13181
13182 pub fn re_edit_selected_stop(&mut self) {
13184 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13185 let Some(stop) = self
13186 .state
13187 .worker_route_editor
13188 .as_ref()
13189 .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
13190 else {
13191 self.state
13192 .push_log("Route: no stop selected — press a to add one".to_string());
13193 return;
13194 };
13195 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13196 ed.begin_edit_selected();
13197 }
13198 match stop {
13199 WorkerRouteStop::Waypoint { .. } => {
13200 self.re_open_sheet(S::WaypointMenu { index: 0 });
13201 }
13202 WorkerRouteStop::HarvestNode { node_id } => {
13203 let nodes = self.state.route_editor_node_candidates();
13204 if nodes.is_empty() {
13205 self.re_cancel_edit();
13206 self.state
13207 .push_log("Route: no harvestable nodes in this region to retarget".to_string());
13208 } else {
13209 let mut picked = std::collections::BTreeSet::new();
13210 picked.insert(node_id.clone());
13211 let index = nodes
13212 .iter()
13213 .position(|n| n.id == node_id)
13214 .map(|i| i + 1)
13215 .unwrap_or(1);
13216 self.re_open_harvest_picker(index, picked);
13217 }
13218 }
13219 WorkerRouteStop::WithdrawFrom { container_id, .. } => {
13220 let containers = self.re_container_candidates();
13223 if containers.is_empty() {
13224 self.re_cancel_edit();
13225 self.state
13226 .push_log("Route: place a storage chest first".to_string());
13227 } else {
13228 let index = containers
13229 .iter()
13230 .position(|c| c.id == container_id)
13231 .unwrap_or(0);
13232 self.re_open_sheet(S::WithdrawContainers { index });
13233 }
13234 }
13235 WorkerRouteStop::DepositAt { container_id, .. } => {
13236 let containers = self.re_container_candidates();
13237 if containers.is_empty() {
13238 self.re_cancel_edit();
13239 self.state
13240 .push_log("Route: place a storage chest first".to_string());
13241 } else {
13242 let index = containers
13243 .iter()
13244 .position(|c| c.id == container_id)
13245 .unwrap_or(0);
13246 self.re_open_sheet(S::DepositContainers { index });
13247 }
13248 }
13249 WorkerRouteStop::TradeWith { npc_id, .. } => {
13250 let npcs = self.re_npc_candidates();
13251 let index = npc_id
13253 .as_ref()
13254 .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
13255 .unwrap_or(0);
13256 self.re_open_sheet(S::SellNpcs { index });
13257 }
13258 WorkerRouteStop::ListOnMarket { .. } => {
13259 self.re_open_market_list_item();
13260 }
13261 WorkerRouteStop::CraftAt { blueprint, .. } => {
13262 let bps = self.re_blueprint_ids();
13263 let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
13264 if bps.is_empty() {
13265 self.re_cancel_edit();
13266 self.state
13267 .push_log("Route: no known blueprints to retarget".to_string());
13268 } else {
13269 self.re_open_sheet(S::CraftBlueprint { index });
13270 }
13271 }
13272 WorkerRouteStop::CultivatePlot { .. } => {
13273 self.re_open_farm_plot_picker(
13274 crate::worker_route_editor::FarmPlotAction::Cultivate,
13275 );
13276 }
13277 WorkerRouteStop::PlantPlot { .. } => {
13278 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
13279 }
13280 WorkerRouteStop::HarvestPlot { .. } => {
13281 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
13282 }
13283 WorkerRouteStop::RestIfNeeded => {
13284 self.re_cancel_edit();
13285 self.state
13286 .push_log("Route: rest has no settings (change the bed with l)".to_string());
13287 }
13288 WorkerRouteStop::Wait { wait_ticks } => {
13289 self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
13290 }
13291 }
13292 }
13293
13294 fn re_cancel_edit(&mut self) {
13295 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13296 ed.editing_index = None;
13297 }
13298 }
13299
13300 pub fn worker_route_editor_ui_click(
13303 &mut self,
13304 click: crate::worker_route_editor::RouteEditorClick,
13305 ) {
13306 use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
13307 match click {
13308 RouteEditorClick::SelectStop(i) => {
13309 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13310 ed.sheet = S::Stops;
13311 ed.select_stop(i);
13312 }
13313 }
13314 RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
13315 RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
13316 RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
13317 }
13318 }
13319
13320 pub fn re_sheet_row_activate(&mut self, row: usize) {
13322 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13323 let Some(sheet) = self
13324 .state
13325 .worker_route_editor
13326 .as_ref()
13327 .map(|ed| ed.sheet.clone())
13328 else {
13329 return;
13330 };
13331 match sheet {
13332 S::Stops => {
13333 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13334 ed.select_stop(row);
13335 }
13336 }
13337 S::AddMenu { .. } => match row {
13338 0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
13339 1 => {
13340 if self.re_node_candidates().is_empty() {
13341 self.state.push_log(
13342 "Route: no harvestable nodes in this region".to_string(),
13343 );
13344 } else {
13345 self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
13346 }
13347 }
13348 2 | 3 => {
13349 if self.re_container_candidates().is_empty() {
13350 self.state
13351 .push_log("Route: place a storage chest first".to_string());
13352 } else if row == 2 {
13353 self.re_open_sheet(S::WithdrawContainers { index: 0 });
13354 } else {
13355 self.re_open_sheet(S::DepositContainers { index: 0 });
13356 }
13357 }
13358 4 => {
13359 if self.re_template_candidates().is_empty() {
13360 self.state.push_log(
13361 "Route: no item templates available — learn a craft recipe or place a harvest node first"
13362 .to_string(),
13363 );
13364 } else {
13365 self.re_open_sheet(S::SellNpcs { index: 0 });
13366 }
13367 }
13368 5 => {
13369 if self.re_template_candidates().is_empty() {
13370 self.state.push_log(
13371 "Route: no item templates available — learn a craft recipe or place a harvest node first"
13372 .to_string(),
13373 );
13374 } else {
13375 self.re_open_market_list_item();
13376 }
13377 }
13378 6 => {
13379 if self.re_blueprint_ids().is_empty() {
13380 self.state.push_log(
13381 "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
13382 .to_string(),
13383 );
13384 } else {
13385 self.re_open_sheet(S::CraftBlueprint { index: 0 });
13386 }
13387 }
13388 7 => self.re_confirm_stop(
13389 WorkerRouteStop::RestIfNeeded,
13390 "rest at lodging (if needed)".into(),
13391 ),
13392 8 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
13393 9 => self.re_open_farm_plot_picker(
13394 crate::worker_route_editor::FarmPlotAction::Cultivate,
13395 ),
13396 10 => {
13397 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant)
13398 }
13399 11 => self
13400 .re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
13401 _ => {}
13402 },
13403 S::WaypointMenu { .. } => match row {
13404 0 => {
13405 let (x, y, z) = self.state.player_position_with_z();
13406 let stop = WorkerRouteStop::Waypoint { x, y, z };
13407 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
13408 }
13409 1 => {
13410 self.re_open_sheet(S::WaypointMapPick);
13411 self.state.push_log(
13412 "Route: click the map to place the waypoint (Esc to finish)".to_string(),
13413 );
13414 }
13415 _ => {}
13416 },
13417 S::HarvestPicker { .. } => {
13418 let mut log: Option<String> = None;
13419 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13420 let S::HarvestPicker {
13421 index: sheet_index,
13422 picked,
13423 nodes,
13424 } = &mut ed.sheet
13425 else {
13426 return;
13427 };
13428 *sheet_index = row;
13429 if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
13430 if picked.is_empty() {
13431 log = Some(
13432 "Route: pick at least one node (Space toggles, Done confirms)"
13433 .into(),
13434 );
13435 } else {
13436 let ids: Vec<String> = picked.iter().cloned().collect();
13437 let added = ed.confirm_harvest_picks(&ids);
13438 log = Some(format!("Route: + {added} harvest stop(s)"));
13439 }
13440 } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
13441 if picked.contains(&n.id) {
13442 picked.remove(&n.id);
13443 } else {
13444 picked.insert(n.id.clone());
13445 }
13446 }
13447 }
13448 if let Some(msg) = log {
13449 self.state.push_log(msg);
13450 }
13451 }
13452 S::WithdrawContainers { .. } => {
13453 let containers = self.re_container_candidates();
13454 if let Some(c) = containers.get(row) {
13455 let id = c.id.clone();
13456 self.re_open_withdraw_items(id);
13457 }
13458 }
13459 S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
13460 S::DepositContainers { .. } => {
13461 let containers = self.re_container_candidates();
13462 if let Some(c) = containers.get(row) {
13463 let id = c.id.clone();
13464 self.re_open_deposit_filter(id);
13465 }
13466 }
13467 S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
13468 S::SellNpcs { .. } => {
13469 let templates = self.re_template_candidates();
13470 let npcs = self.re_npc_candidates();
13471 if row == 0 {
13472 if !crate::worker_route_editor::any_trade_npc_buys_route_item(
13473 &self.state.npcs,
13474 &templates,
13475 ) {
13476 self.state.push_log(
13477 crate::worker_route_editor::sell_merchant_empty_reason(
13478 None,
13479 &self.state.npcs,
13480 &templates,
13481 ),
13482 );
13483 return;
13484 }
13485 self.re_open_sell_item(None);
13486 return;
13487 }
13488 let Some(n) = npcs.get(row - 1) else {
13489 return;
13490 };
13491 if !n.buys_route_item {
13492 self.state.push_log(
13493 crate::worker_route_editor::sell_merchant_empty_reason(
13494 Some(n.id.as_str()),
13495 &self.state.npcs,
13496 &templates,
13497 ),
13498 );
13499 return;
13500 }
13501 self.re_open_sell_item(Some(n.id.clone()));
13502 }
13503 S::SellItem { .. } => self.re_sell_item_activate(row),
13504 S::MarketListItem { .. } => self.re_market_list_item_activate(row),
13505 S::CraftBlueprint { .. } => {
13506 let bps = self.re_blueprint_ids();
13507 if let Some(bp) = bps.get(row) {
13508 let stop = WorkerRouteStop::CraftAt {
13509 device: "hand".into(),
13510 blueprint: bp.clone(),
13511 qty: None,
13512 };
13513 self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
13514 }
13515 }
13516 S::WaitEntry { ticks } => {
13517 let stop = WorkerRouteStop::Wait { wait_ticks: ticks };
13518 self.re_confirm_stop(stop, format!("wait {ticks}t"));
13519 }
13520 S::BedPicker { .. } => {
13521 let beds = self.re_bed_candidates();
13522 if let Some((id, name)) = beds.get(row) {
13523 let (id, name) = (id.clone(), name.clone());
13524 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13525 ed.lodging_container_id = Some(id.clone());
13526 ed.sheet = S::Stops;
13527 }
13528 self.state
13529 .push_log(format!("Route: rest bed set to {name}"));
13530 }
13531 }
13532 S::FarmPlotPicker { action, .. } => {
13533 let plots = self.re_farm_plot_candidates();
13534 let Some(plot) = plots.get(row).cloned() else {
13535 return;
13536 };
13537 match action {
13538 crate::worker_route_editor::FarmPlotAction::Cultivate => {
13539 let label = plot_route_label(&plot);
13540 self.re_confirm_stop(
13541 WorkerRouteStop::CultivatePlot {
13542 plot_id: plot.plot_id,
13543 },
13544 format!("cultivate {label}"),
13545 );
13546 }
13547 crate::worker_route_editor::FarmPlotAction::Harvest => {
13548 let label = plot_route_label(&plot);
13549 self.re_confirm_stop(
13550 WorkerRouteStop::HarvestPlot {
13551 plot_id: plot.plot_id,
13552 },
13553 format!("harvest {label}"),
13554 );
13555 }
13556 crate::worker_route_editor::FarmPlotAction::Plant => {
13557 let seeds = self.re_farm_seed_candidates();
13558 if seeds.is_empty() {
13559 self.state.push_log(
13560 "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
13561 );
13562 return;
13563 }
13564 self.re_open_sheet(S::FarmPlantSeed {
13565 plot_id: plot.plot_id,
13566 seeds,
13567 index: 0,
13568 });
13569 }
13570 }
13571 }
13572 S::FarmPlantSeed { plot_id, seeds, .. } => {
13573 if let Some(seed) = seeds.get(row).cloned() {
13574 self.re_confirm_stop(
13575 WorkerRouteStop::PlantPlot {
13576 plot_id,
13577 seed_template: seed.clone(),
13578 },
13579 format!("plant {seed}"),
13580 );
13581 }
13582 }
13583 S::WaypointMapPick => {}
13584 }
13585 }
13586
13587 fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
13588 use crate::worker_route_editor::RouteEditorSheet as S;
13589 if self.re_farm_plot_candidates().is_empty() {
13590 self.state
13591 .push_log("Route: no farmable plots visible — claim land or get farm access first");
13592 return;
13593 }
13594 self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
13595 }
13596
13597 fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
13598 self.state
13599 .property_plots
13600 .iter()
13601 .filter(|p| p.is_mine || p.may_farm)
13602 .cloned()
13603 .collect()
13604 }
13605
13606 fn re_farm_seed_candidates(&self) -> Vec<String> {
13610 let mut set = std::collections::BTreeSet::new();
13611 let looks_like_seed = |id: &str, catalog: &std::collections::HashMap<String, ItemCatalogEntryView>| {
13612 catalog.get(id).is_some_and(|e| e.is_farm_seed()) || id.ends_with("_seed")
13613 };
13614 for (id, _, _) in self.state.farm_seed_entries() {
13615 set.insert(id);
13616 }
13617 for c in &self.state.placed_containers {
13618 let mine = match (self.state.character_id, c.owner_character_id) {
13619 (Some(a), Some(b)) => a == b,
13620 _ => false,
13621 };
13622 if !mine {
13623 continue;
13624 }
13625 for s in &c.contents {
13626 if s.quantity > 0
13627 && (s.props.contains_key("seed_for")
13628 || looks_like_seed(&s.template_id, &self.state.item_catalog))
13629 {
13630 set.insert(s.template_id.clone());
13631 }
13632 }
13633 }
13634 if let Some(ed) = self.state.worker_route_editor.as_ref() {
13635 for stop in &ed.stops {
13636 if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } =
13637 stop
13638 {
13639 for it in items {
13640 if looks_like_seed(&it.template, &self.state.item_catalog) {
13641 set.insert(it.template.clone());
13642 }
13643 }
13644 }
13645 if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
13646 seed_template,
13647 ..
13648 } = stop
13649 {
13650 if !seed_template.is_empty() {
13651 set.insert(seed_template.clone());
13652 }
13653 }
13654 }
13655 }
13656 for (id, entry) in &self.state.item_catalog {
13657 if entry.is_farm_seed() {
13658 set.insert(id.clone());
13659 }
13660 }
13661 set.into_iter().collect()
13662 }
13663
13664 pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
13671 use crate::worker_route_editor as wre;
13672 use wre::RouteEditorSheet as S;
13673 if self.state.worker_route_editor.is_none() {
13674 return;
13675 }
13676 let sheet = self
13677 .state
13678 .worker_route_editor
13679 .as_ref()
13680 .map(|ed| ed.sheet.clone())
13681 .unwrap_or(S::Stops);
13682 match sheet {
13683 S::WaypointMapPick => {
13684 let (_, _, z) = self.state.player_position_with_z();
13685 let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
13686 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
13687 let editing = self
13689 .state
13690 .worker_route_editor
13691 .as_ref()
13692 .is_some_and(|ed| ed.editing_index.is_some());
13693 if !editing {
13694 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13695 ed.sheet = S::WaypointMapPick;
13696 }
13697 }
13698 }
13699 S::HarvestPicker { .. } => {
13700 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
13701 let mut log: Option<String> = None;
13702 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13703 let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
13704 return;
13705 };
13706 let selected = if picked.contains(&node.id) {
13707 picked.remove(&node.id);
13708 false
13709 } else {
13710 picked.insert(node.id.clone());
13711 true
13712 };
13713 log = Some(format!(
13714 "Route: {} {}",
13715 if selected { "selected" } else { "deselected" },
13716 resource_node_route_label(node)
13717 ));
13718 }
13719 if let Some(msg) = log {
13720 self.state.push_log(msg);
13721 }
13722 }
13723 }
13724 S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
13725 let inside = self.state.effective_inside_building();
13727 if let Some(cid) = wre::pick_storage_container_at(
13728 &self.state.placed_containers,
13729 self.state.character_id,
13730 x,
13731 y,
13732 inside.as_deref(),
13733 ) {
13734 self.re_open_withdraw_items(cid);
13735 }
13736 }
13737 S::DepositContainers { .. } | S::DepositFilter { .. } => {
13738 let inside = self.state.effective_inside_building();
13739 if let Some(cid) = wre::pick_storage_container_at(
13740 &self.state.placed_containers,
13741 self.state.character_id,
13742 x,
13743 y,
13744 inside.as_deref(),
13745 ) {
13746 self.re_open_deposit_filter(cid);
13747 }
13748 }
13749 S::SellNpcs { .. } => {
13750 if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13751 self.re_open_sell_item(Some(npc_id));
13752 }
13753 }
13754 S::SellItem { .. } => {
13755 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13756 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13757 if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
13758 *slot = Some(npc_id.clone());
13759 }
13760 }
13761 self.state
13762 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
13763 }
13764 }
13765 _ => self.worker_route_editor_quick_add_click(x, y),
13767 }
13768 }
13769
13770 fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
13774 use crate::worker_route_editor as wre;
13775 let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
13776 let dx = ax - bx;
13777 let dy = ay - by;
13778 (dx * dx + dy * dy).sqrt()
13779 };
13780
13781 let selected_stop_kind = self
13784 .state
13785 .worker_route_editor
13786 .as_ref()
13787 .and_then(|ed| ed.stops.get(ed.selected_stop_index))
13788 .map(|s| match s {
13789 wre::WorkerRouteStop::TradeWith { .. } => 1,
13790 wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
13791 _ => 0,
13792 })
13793 .unwrap_or(0);
13794 if selected_stop_kind == 1 {
13795 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13796 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13797 ed.set_selected_trade_npc(npc_id.clone());
13798 }
13799 self.state
13800 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
13801 return;
13802 }
13803 }
13804 if selected_stop_kind == 2 {
13805 let inside = self.state.effective_inside_building();
13806 if let Some(cid) = wre::pick_storage_container_at(
13807 &self.state.placed_containers,
13808 self.state.character_id,
13809 x,
13810 y,
13811 inside.as_deref(),
13812 ) {
13813 let name = self
13814 .state
13815 .placed_containers
13816 .iter()
13817 .find(|c| c.id == cid)
13818 .map(|c| c.display_name.clone())
13819 .unwrap_or_else(|| "container".into());
13820 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13821 ed.set_selected_withdraw_container(cid.clone());
13822 }
13823 self.state
13824 .push_log(format!("Route: withdraw source → {name}"));
13825 return;
13826 }
13827 }
13828
13829 enum Target {
13832 Bed(String),
13833 Container(String),
13834 Npc(String, String),
13835 Node(String, String),
13836 }
13837 let mut best: Option<(f32, u8, Target)> = None;
13838 let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
13839 let better = match best {
13840 None => true,
13841 Some((bd, brank, _)) => {
13842 d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank)
13843 }
13844 };
13845 if better {
13846 *best = Some((d, rank, t));
13847 }
13848 };
13849 let inside = self.state.effective_inside_building();
13850 if let Some(bed_id) = wre::pick_lodging_container_at(
13851 &self.state.placed_containers,
13852 self.state.character_id,
13853 x,
13854 y,
13855 inside.as_deref(),
13856 ) {
13857 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
13858 let already_bed =
13861 self.state.worker_route_editor.as_ref().is_some_and(|ed| {
13862 ed.lodging_container_id.as_deref() == Some(bed_id.as_str())
13863 });
13864 if already_bed {
13865 consider(
13866 dist(x, y, c.x, c.y),
13867 1,
13868 Target::Container(bed_id),
13869 &mut best,
13870 );
13871 } else {
13872 consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
13873 }
13874 }
13875 }
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 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
13884 consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
13885 }
13886 }
13887 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13888 if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
13889 consider(
13890 dist(x, y, n.x, n.y),
13891 2,
13892 Target::Npc(npc_id, label),
13893 &mut best,
13894 );
13895 }
13896 }
13897 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
13898 let d = dist(x, y, node.x, node.y);
13899 let label = resource_node_route_label(node);
13900 consider(d, 3, Target::Node(node.id.clone(), label), &mut best);
13901 }
13902
13903 match best.map(|(_, _, t)| t) {
13904 Some(Target::Bed(bed_id)) => {
13905 let name = self
13906 .state
13907 .placed_containers
13908 .iter()
13909 .find(|c| c.id == bed_id)
13910 .map(|c| c.display_name.clone())
13911 .unwrap_or_else(|| "camp bed".into());
13912 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13913 ed.lodging_container_id = Some(bed_id.clone());
13914 }
13915 self.state
13916 .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
13917 }
13918 Some(Target::Container(cid)) => {
13919 let name = self
13920 .state
13921 .placed_containers
13922 .iter()
13923 .find(|c| c.id == cid)
13924 .map(|c| c.display_name.clone())
13925 .unwrap_or_else(|| "container".into());
13926 let added = self
13927 .state
13928 .worker_route_editor
13929 .as_mut()
13930 .is_some_and(|ed| ed.append_deposit_at(&cid));
13931 if added {
13932 self.state
13933 .push_log(format!("Route: + deposit at {name} ({cid})"));
13934 } else {
13935 self.state.push_log(format!(
13936 "Route: {name} already in route — selected it (d to remove)"
13937 ));
13938 }
13939 }
13940 Some(Target::Npc(npc_id, label)) => {
13941 let template = self.re_template_candidates().into_iter().next();
13944 let Some(template) = template else {
13945 self.state.push_log(
13946 "Route: no items in your storage to sell — stock a chest first".to_string(),
13947 );
13948 return;
13949 };
13950 let added = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
13951 ed.append_trade_with(template.clone(), Some(npc_id.clone()), true)
13952 });
13953 if added {
13954 self.state
13955 .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
13956 } else {
13957 self.state.push_log(format!(
13958 "Route: {label} already sells {template} — selected it (d to remove)"
13959 ));
13960 }
13961 }
13962 Some(Target::Node(id, label)) => {
13963 let added = self
13964 .state
13965 .worker_route_editor
13966 .as_mut()
13967 .is_some_and(|ed| ed.append_harvest_node(&id));
13968 if added {
13969 self.state
13970 .push_log(format!("Route: + harvest node {label}"));
13971 } else {
13972 self.state.push_log(format!(
13973 "Route: {label} already in route — selected it (d to remove)"
13974 ));
13975 }
13976 }
13977 None => {}
13978 }
13979 }
13980
13981 pub fn worker_route_editor_select(&mut self, delta: i32) {
13982 let Some(ed) = self.state.worker_route_editor.as_mut() else {
13983 return;
13984 };
13985 if ed.stops.is_empty() {
13986 return;
13987 }
13988 let n = ed.stops.len() as i32;
13989 let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
13990 ed.selected_stop_index = next;
13991 }
13992
13993 pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
13994 let Some(ed) = self.state.worker_route_editor.as_mut() else {
13995 return;
13996 };
13997 if delta < 0 {
13998 ed.move_selected_up();
13999 } else if delta > 0 {
14000 ed.move_selected_down();
14001 }
14002 }
14003
14004 pub fn worker_route_editor_delete_selected(&mut self) {
14005 let removed = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
14006 let before = ed.stop_count();
14007 ed.remove_selected_stop();
14008 ed.stop_count() < before
14009 });
14010 if removed {
14011 self.state.push_log("Route: removed selected stop");
14012 }
14013 }
14014
14015 pub fn worker_route_editor_clear_stops(&mut self) {
14018 let Some(ed) = self.state.worker_route_editor.as_mut() else {
14019 return;
14020 };
14021 if ed.stops.is_empty() {
14022 self.state
14023 .push_log("Route: already empty — s saves an idle worker".to_string());
14024 return;
14025 }
14026 ed.stops.clear();
14027 ed.selected_stop_index = 0;
14028 self.state.push_log(
14029 "Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string(),
14030 );
14031 }
14032
14033 pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
14034 if self.state.pending_worker_job_ack.is_some() {
14035 anyhow::bail!("route save still pending — wait for server ack");
14036 }
14037 let Some(ed) = self.state.worker_route_editor.clone() else {
14038 anyhow::bail!("route editor not open");
14039 };
14040 let (job_yaml, idle) = if ed.stops.is_empty() {
14043 (ed.build_idle_job_yaml(), true)
14044 } else {
14045 (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
14046 };
14047 let worker_id = ed.worker_instance_id.clone();
14048 let route_view = if idle { None } else { Some(ed.to_route_view()) };
14049 let mode = if idle {
14050 flatland_protocol::WorkerModeView::Idle
14051 } else {
14052 flatland_protocol::WorkerModeView::JobLoop
14053 };
14054 let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
14055 .state
14056 .hired_workers
14057 .iter()
14058 .find(|w| w.instance_id == worker_id)
14059 .map(|w| {
14060 (
14061 w.route.clone(),
14062 w.mode,
14063 w.step_label.clone(),
14064 w.last_error.clone(),
14065 )
14066 })
14067 .unwrap_or((
14068 None,
14069 flatland_protocol::WorkerModeView::Idle,
14070 String::new(),
14071 None,
14072 ));
14073 self.seq += 1;
14074 let seq = self.seq;
14075 self.session
14076 .submit_intent(Intent::SetWorkerJob {
14077 entity_id: self.state.entity_id,
14078 worker_instance_id: worker_id.clone(),
14079 job_yaml,
14080 seq,
14081 })
14082 .await?;
14083 self.state.intents_sent += 1;
14084 if let Some(w) = self
14085 .state
14086 .hired_workers
14087 .iter_mut()
14088 .find(|w| w.instance_id == worker_id)
14089 {
14090 w.route = route_view;
14091 w.mode = mode;
14092 w.last_error = None;
14093 if idle {
14094 w.step_label.clear();
14095 w.route_stop_index = None;
14096 }
14097 }
14098 self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
14099 seq,
14100 worker_instance_id: worker_id,
14101 worker_label: ed.worker_label.clone(),
14102 idle,
14103 stop_count: ed.stops.len(),
14104 prev_route,
14105 prev_mode,
14106 prev_step_label,
14107 prev_last_error,
14108 });
14109 self.state.push_log(format!(
14110 "Route: saving for {}… (waiting for server)",
14111 ed.worker_label
14112 ));
14113 Ok(())
14115 }
14116 pub fn quest_menu_move(&mut self, delta: i32) {
14117 let n = self.state.active_quest_entries().len();
14118 if n == 0 {
14119 return;
14120 }
14121 let idx = self.state.quest_menu_index as i32;
14122 self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
14123 }
14124
14125 pub fn quest_menu_page(&mut self, pages: i32) {
14126 let n = self.state.active_quest_entries().len();
14127 self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
14128 }
14129
14130 pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
14131 let Some(offer) = self.state.selected_quest_offer().cloned() else {
14132 anyhow::bail!("no quest offer");
14133 };
14134 self.seq += 1;
14135 let seq = self.seq;
14136 self.session
14137 .submit_intent(Intent::AcceptQuest {
14138 entity_id: self.state.entity_id,
14139 quest_id: offer.quest_id,
14140 seq,
14141 })
14142 .await?;
14143 self.state.intents_sent += 1;
14144 Ok(())
14145 }
14146
14147 pub fn quest_offer_move(&mut self, delta: i32) {
14148 self.state.move_quest_offer_selection(delta);
14149 }
14150
14151 pub fn quest_offer_decline(&mut self) {
14152 self.state.clear_quest_offers();
14153 if !self.state.show_npc_chat
14154 && !self.state.show_shop_menu
14155 && self.state.npc_verb_target.is_some()
14156 {
14157 self.state.show_npc_verb_menu = true;
14158 }
14159 }
14160
14161 pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
14162 if !self.state.show_quest_menu {
14163 return Ok(());
14164 }
14165 let active: Vec<_> = self
14166 .state
14167 .active_quest_entries()
14168 .into_iter()
14169 .cloned()
14170 .collect();
14171 let Some(entry) = active.get(self.state.quest_menu_index) else {
14172 return Ok(());
14173 };
14174 if self.state.quest_withdraw_confirm {
14175 if !entry.can_withdraw {
14176 anyhow::bail!("quest cannot be withdrawn");
14177 }
14178 self.seq += 1;
14179 let seq = self.seq;
14180 self.session
14181 .submit_intent(Intent::WithdrawQuest {
14182 entity_id: self.state.entity_id,
14183 quest_id: entry.quest_id.clone(),
14184 seq,
14185 })
14186 .await?;
14187 self.state.intents_sent += 1;
14188 self.state.quest_withdraw_confirm = false;
14189 return Ok(());
14190 }
14191 self.seq += 1;
14192 let seq = self.seq;
14193 self.session
14194 .submit_intent(Intent::TrackQuest {
14195 entity_id: self.state.entity_id,
14196 quest_id: entry.quest_id.clone(),
14197 seq,
14198 })
14199 .await?;
14200 self.state.intents_sent += 1;
14201 Ok(())
14202 }
14203
14204 pub fn quest_request_withdraw(&mut self) {
14205 if self.state.show_quest_menu {
14206 self.state.quest_withdraw_confirm = true;
14207 }
14208 }
14209
14210 pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
14211 if !self.state.is_alive() {
14212 anyhow::bail!("you are dead");
14213 }
14214 let Some(catalog) = self.state.shop_catalog.clone() else {
14215 anyhow::bail!("no shop open");
14216 };
14217 self.seq += 1;
14218 let seq = self.seq;
14219 match self.state.shop_tab {
14220 ShopTab::Buy => {
14221 let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
14222 anyhow::bail!("nothing selected");
14223 };
14224 if offer.already_owned {
14225 anyhow::bail!("already owned");
14226 }
14227 self.session
14228 .submit_intent(Intent::ShopBuy {
14229 entity_id: self.state.entity_id,
14230 npc_id: catalog.npc_id.clone(),
14231 offer_id: offer.offer_id.clone(),
14232 quantity: self.state.shop_quantity,
14233 seq,
14234 })
14235 .await?;
14236 }
14237 ShopTab::Sell => {
14238 let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
14239 anyhow::bail!("nothing to sell");
14240 };
14241 if line.quantity == 0 {
14242 anyhow::bail!("you have no {}", line.label);
14243 }
14244 let quantity = self.state.shop_quantity.min(line.quantity).max(1);
14245 self.session
14246 .submit_intent(Intent::ShopSell {
14247 entity_id: self.state.entity_id,
14248 npc_id: catalog.npc_id.clone(),
14249 template_id: line.template_id.clone(),
14250 quantity,
14251 seq,
14252 })
14253 .await?;
14254 }
14255 }
14256 self.state.intents_sent += 1;
14257 Ok(())
14258 }
14259
14260 pub fn craft_menu_move(&mut self, delta: i32) {
14261 let n = self.state.craft_filtered_indices().len();
14262 if n == 0 {
14263 return;
14264 }
14265 let idx = self.state.craft_menu_index as i32;
14266 let next = (idx + delta).rem_euclid(n as i32);
14267 self.state.craft_menu_index = next as usize;
14268 self.state.clamp_craft_batch_quantity();
14269 }
14270
14271 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
14272 self.state.craft_batch_adjust_quantity(delta);
14273 }
14274
14275 pub fn craft_batch_set_max(&mut self) {
14276 self.state.craft_batch_set_max();
14277 }
14278
14279 pub fn craft_batch_set_min(&mut self) {
14280 self.state.craft_batch_set_min();
14281 }
14282
14283 pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
14284 let Some(blueprint) = self.state.craft_selected_blueprint().cloned() else {
14285 anyhow::bail!("no blueprints in this tab");
14286 };
14287 if !self.state.can_craft_blueprint(&blueprint) {
14288 let hint = self
14289 .state
14290 .craft_missing_hint(&blueprint)
14291 .unwrap_or_else(|| "missing materials".into());
14292 anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
14293 }
14294 let count = self.state.craft_batch_quantity;
14295 let max = self.state.max_craft_batches(&blueprint);
14296 if max == 0 {
14297 anyhow::bail!("cannot craft {}", blueprint.label);
14298 }
14299 let batches = count.min(max);
14300 self.craft(&blueprint.id, Some(batches)).await?;
14301 Ok(())
14303 }
14304
14305 pub async fn move_by(
14306 &mut self,
14307 forward: f32,
14308 strafe: f32,
14309 vertical: f32,
14310 sprint: bool,
14311 sneak: bool,
14312 ) -> anyhow::Result<()> {
14313 if !self.state.is_alive() {
14314 anyhow::bail!("you are dead");
14315 }
14316 if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
14317 self.last_move_forward = forward;
14318 self.last_move_strafe = strafe;
14319 }
14320 self.seq += 1;
14321 self.session
14322 .submit_intent(Intent::Move {
14323 entity_id: self.state.entity_id,
14324 forward,
14325 strafe,
14326 vertical,
14327 sprint: sprint && !sneak,
14328 sneak,
14329 seq: self.seq,
14330 })
14331 .await?;
14332 self.state.intents_sent += 1;
14333 Ok(())
14334 }
14335
14336 pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
14337 if !self.state.connected {
14338 crate::harvest_trace!("harvest_nearest rejected: not connected");
14339 anyhow::bail!("not connected");
14340 }
14341 if !self.state.is_alive() {
14342 crate::harvest_trace!("harvest_nearest rejected: player dead");
14343 anyhow::bail!("you are dead");
14344 }
14345 if self.state.harvest_in_progress {
14346 if self.state.harvest_state_stale() {
14347 self.state.clear_harvest_state();
14348 } else {
14349 anyhow::bail!("already harvesting");
14350 }
14351 }
14352 let (px, py) = self
14353 .state
14354 .player
14355 .as_ref()
14356 .map(|p| (p.transform.position.x, p.transform.position.y))
14357 .unwrap_or((0.0, 0.0));
14358
14359 let available = self
14360 .state
14361 .resource_nodes
14362 .iter()
14363 .filter(|n| !n.harvest_off)
14364 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
14365 .count();
14366 let node_id = self
14367 .state
14368 .resource_nodes
14369 .iter()
14370 .filter(|n| !n.harvest_off)
14371 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
14372 .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
14373 .min_by(|a, b| {
14374 let da = distance(px, py, a.x, a.y);
14375 let db = distance(px, py, b.x, b.y);
14376 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
14377 })
14378 .map(|n| n.id.clone());
14379
14380 let Some(node_id) = node_id else {
14381 let has_loot = self
14382 .state
14383 .ground_drops
14384 .iter()
14385 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
14386 if has_loot {
14387 return self.pickup_nearest().await;
14388 }
14389 anyhow::bail!(
14390 "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
14391 );
14392 };
14393
14394 self.seq += 1;
14395 let seq = self.seq;
14396 crate::harvest_trace!(
14397 entity_id = self.state.entity_id,
14398 node_id = %node_id,
14399 seq,
14400 px,
14401 py,
14402 available_nodes = available,
14403 "submitting harvest intent"
14404 );
14405 self.session
14406 .submit_intent(Intent::Harvest {
14407 entity_id: self.state.entity_id,
14408 node_id,
14409 seq,
14410 })
14411 .await?;
14412 self.state.intents_sent += 1;
14413 self.state.harvest_in_progress = true;
14414 self.state.harvest_started_at = Some(Instant::now());
14415 self.state.push_log("Harvesting…");
14416 crate::harvest_trace!(
14417 entity_id = self.state.entity_id,
14418 seq,
14419 "harvest intent queued to session"
14420 );
14421 Ok(())
14422 }
14423
14424 pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
14425 if !self.state.is_alive() {
14426 anyhow::bail!("you are dead");
14427 }
14428 let blueprint_id = self
14429 .state
14430 .blueprints
14431 .iter()
14432 .find(|bp| self.state.can_craft_blueprint(bp))
14433 .map(|bp| bp.id.clone())
14434 .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
14435 self.craft(&blueprint_id, None).await
14436 }
14437
14438 pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
14439 if !self.state.is_alive() {
14440 anyhow::bail!("you are dead");
14441 }
14442 self.seq += 1;
14443 self.session
14444 .submit_intent(Intent::Craft {
14445 entity_id: self.state.entity_id,
14446 blueprint_id: blueprint_id.to_string(),
14447 count,
14448 seq: self.seq,
14449 })
14450 .await?;
14451 self.state.intents_sent += 1;
14452 let (label, batches) = self
14453 .state
14454 .blueprints
14455 .iter()
14456 .find(|b| b.id == blueprint_id)
14457 .map(|b| {
14458 let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
14459 (b.label.as_str(), n)
14460 })
14461 .unwrap_or((blueprint_id, count.unwrap_or(1)));
14462 self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
14463 self.state.craft_channel_blueprint_id = Some(blueprint_id.to_string());
14464 Ok(())
14465 }
14466
14467 pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
14468 if !self.state.is_alive() {
14469 anyhow::bail!("you are dead");
14470 }
14471 let target_id = match self.state.nearest_interact_target() {
14472 Some(id) => id,
14473 None => {
14474 anyhow::bail!("nothing to interact with nearby");
14475 }
14476 };
14477 if self.state.npcs.iter().any(|n| n.id == target_id) {
14478 self.state.show_npc_verb_menu = true;
14479 self.state.npc_verb_target = Some(target_id);
14480 self.state.npc_verb_index = 0;
14481 self.state.npc_verb_notice = None;
14482 return Ok(());
14483 }
14484 if self
14485 .state
14486 .hired_workers
14487 .iter()
14488 .any(|w| w.instance_id == target_id)
14489 {
14490 return self.open_workers_menu_for(&target_id).await;
14491 }
14492 if let Ok(peer_id) = target_id.parse::<EntityId>() {
14493 if self
14494 .state
14495 .hired_workers
14496 .iter()
14497 .any(|w| w.entity_id == peer_id)
14498 {
14499 if let Some(w) = self
14500 .state
14501 .hired_workers
14502 .iter()
14503 .find(|w| w.entity_id == peer_id)
14504 {
14505 let id = w.instance_id.clone();
14506 return self.open_workers_menu_for(&id).await;
14507 }
14508 }
14509 if let Some(entity) = self
14510 .state
14511 .entities
14512 .iter()
14513 .find(|e| e.id == peer_id && e.id != self.state.entity_id)
14514 {
14515 self.state.player_verbs.open_for(peer_id, &entity.label);
14516 return Ok(());
14517 }
14518 }
14519 self.seq += 1;
14520 self.session
14521 .submit_intent(Intent::Interact {
14522 entity_id: self.state.entity_id,
14523 target_id: target_id.clone(),
14524 seq: self.seq,
14525 })
14526 .await?;
14527 self.state.intents_sent += 1;
14528 Ok(())
14529 }
14530
14531 pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
14536 if !self.state.is_alive() {
14537 anyhow::bail!("you are dead");
14538 }
14539 let (px, py) = self.state.player_position();
14540 let has_loot = self
14541 .state
14542 .ground_drops
14543 .iter()
14544 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
14545 if has_loot {
14546 return self.pickup_nearest().await;
14547 }
14548
14549 if let Some(primary) = self.state.probe_use_world().primary {
14551 match primary.kind.cascade_stage() {
14552 0 => return self.interact_nearest().await,
14553 2 => return self.pickup_nearest_container().await,
14554 3 => return self.harvest_nearest().await,
14555 _ => {}
14556 }
14557 }
14558
14559 if let Some(plot) = self.state.my_plot_under_player().cloned() {
14560 const SELL_WINDOW: Duration = Duration::from_millis(1200);
14562 let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
14563 && self
14564 .state
14565 .sell_plot_armed_at
14566 .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
14567 if sell_armed {
14568 return self.confirm_sell_plot_to_crown(plot.plot_id).await;
14569 }
14570 self.state.sell_plot_confirm = None;
14571 self.state.sell_plot_armed_at = None;
14572
14573 let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
14576 self.state.npcs.iter().any(|n| n.id == id)
14577 || self.state.hired_workers.iter().any(|w| w.instance_id == id)
14578 || self.state.doors.iter().any(|d| d.id == id)
14579 || self.state.interactables.iter().any(|i| {
14580 i.id == id
14581 && matches!(i.kind.as_str(), "quest_board" | "well" | "exit" | "enter")
14582 })
14583 || id.parse::<EntityId>().is_ok_and(|eid| {
14584 self.state
14585 .entities
14586 .iter()
14587 .any(|e| e.id == eid && e.id != self.state.entity_id)
14588 })
14589 });
14590 if !blocking_interact {
14591 match self.harvest_nearest().await {
14593 Ok(()) => return Ok(()),
14594 Err(err) => {
14595 let msg = err.to_string();
14596 if !(msg.contains("no harvestable")
14597 || msg.contains("press p")
14598 || msg.contains("press f")
14599 || msg.contains("nothing"))
14600 {
14601 return Err(err);
14602 }
14603 }
14604 }
14605 return Ok(());
14606 }
14607 }
14608 if self.state.nearest_interact_target().is_some() {
14609 return self.interact_nearest().await;
14610 }
14611 if let Some((label, dist)) = self.state.nearest_quest_board() {
14614 if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
14615 anyhow::bail!(
14616 "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
14617 );
14618 }
14619 }
14620
14621 match self.harvest_nearest().await {
14622 Ok(()) => Ok(()),
14623 Err(err) => {
14624 let msg = err.to_string();
14625 if msg.contains("no harvestable")
14626 || msg.contains("press p")
14627 || msg.contains("press f")
14628 {
14629 anyhow::bail!(
14630 "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
14631 );
14632 }
14633 Err(err)
14634 }
14635 }
14636 }
14637
14638 pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
14640 if !self.state.is_alive() {
14641 anyhow::bail!("you are dead");
14642 }
14643 if self.state.claim_mode.is_some() {
14644 anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
14645 }
14646 let zone = self
14647 .state
14648 .free_property_zone_under_player()
14649 .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
14650 let zone_id = zone.id.clone();
14651 let label = zone
14652 .label
14653 .as_deref()
14654 .filter(|s| !s.trim().is_empty())
14655 .unwrap_or(zone.id.as_str())
14656 .to_string();
14657 self.enter_claim_mode(&zone_id);
14658 self.state.push_log(format!(
14659 "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
14660 ));
14661 Ok(())
14662 }
14663
14664 pub fn enter_claim_mode(&mut self, zone_id: &str) {
14666 let Some(zone) = self
14667 .state
14668 .property_zones
14669 .iter()
14670 .find(|z| z.id == zone_id)
14671 .cloned()
14672 else {
14673 self.state.push_log("unknown property zone");
14674 return;
14675 };
14676 self.state.sell_plot_confirm = None;
14677 self.state.sell_plot_armed_at = None;
14678 let min_area = self
14679 .state
14680 .property_plot_settings
14681 .as_ref()
14682 .map(|s| s.min_plot_area_m2)
14683 .unwrap_or(4.0)
14684 .max(1.0);
14685 let min_side = min_area.sqrt().ceil().max(1.0) as u32;
14686 let side = 4u32.max(min_side);
14687 let (px, py) = self.state.player_position();
14688 let anchor_x = px.floor();
14689 let anchor_y = py.floor();
14690 self.state.claim_mode = Some(ClaimModeState {
14691 zone_id: zone.id.clone(),
14692 width_m: side,
14693 height_m: side,
14694 anchor_x,
14695 anchor_y,
14696 });
14697 let label = zone
14698 .label
14699 .as_deref()
14700 .filter(|s| !s.trim().is_empty())
14701 .unwrap_or(zone.id.as_str());
14702 self.state.push_log(format!(
14703 "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
14704 ));
14705 }
14706
14707 pub fn cancel_claim_mode(&mut self) {
14708 if self.state.claim_mode.take().is_some() {
14709 self.state.push_log("Claim cancelled");
14710 }
14711 }
14712
14713 pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
14715 if !self.state.is_alive() {
14716 anyhow::bail!("you are dead");
14717 }
14718 if self.state.relocate_mode.is_some() {
14719 anyhow::bail!("already relocating — Enter confirm, Esc cancel");
14720 }
14721 if self.state.claim_mode.is_some() {
14722 anyhow::bail!("finish or cancel claim mode first");
14723 }
14724 let chest = self
14725 .state
14726 .placed_containers
14727 .iter()
14728 .find(|c| c.id == container_id)
14729 .cloned()
14730 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
14731 let (px, py) = self.state.player_position();
14732 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
14733 anyhow::bail!("too far from {}", chest.display_name);
14734 }
14735 if chest.locked && !chest.accessible {
14736 anyhow::bail!(
14737 "need the matching key for {} before moving it",
14738 chest.display_name
14739 );
14740 }
14741 let label = if chest.display_name.trim().is_empty() {
14742 chest.template_id.clone()
14743 } else {
14744 chest.display_name.clone()
14745 };
14746 self.state.relocate_mode = Some(RelocateModeState {
14747 container_id: chest.id.clone(),
14748 label: label.clone(),
14749 cursor_x: chest.x.floor() + 0.5,
14750 cursor_y: chest.y.floor() + 0.5,
14751 });
14752 self.state.push_log(format!(
14753 "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
14754 ));
14755 Ok(())
14756 }
14757
14758 pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
14760 let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
14761 anyhow::bail!("no chest nearby to relocate");
14762 };
14763 if chest.locked && !chest.accessible {
14764 anyhow::bail!(
14765 "need the matching key for {} before moving it",
14766 chest.display_name
14767 );
14768 }
14769 self.begin_relocate_container(&chest.id)
14772 }
14773
14774 pub fn cancel_relocate_mode(&mut self) {
14775 if self.state.relocate_mode.take().is_some() {
14776 self.state.push_log("Relocate cancelled");
14777 }
14778 }
14779
14780 pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
14781 let Some(mode) = self.state.relocate_mode.as_mut() else {
14782 return;
14783 };
14784 let max_x = self.state.world_width_m.max(1.0);
14785 let max_y = self.state.world_height_m.max(1.0);
14786 let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
14787 let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
14788 mode.cursor_x = nx.floor() + 0.5;
14789 mode.cursor_y = ny.floor() + 0.5;
14790 }
14791
14792 pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
14793 let Some(mode) = self.state.relocate_mode.as_mut() else {
14794 return;
14795 };
14796 let max_x = self.state.world_width_m.max(1.0);
14797 let max_y = self.state.world_height_m.max(1.0);
14798 mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
14799 mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
14800 }
14801
14802 pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
14803 if !self.state.is_alive() {
14804 anyhow::bail!("you are dead");
14805 }
14806 let Some(mode) = self.state.relocate_mode.clone() else {
14807 anyhow::bail!("not relocating");
14808 };
14809 let (px, py) = self.state.player_position();
14810 let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
14811 if dist > 8.0 {
14812 anyhow::bail!("destination too far (max 8 m)");
14813 }
14814 self.seq += 1;
14815 self.session
14816 .submit_intent(Intent::MovePlacedContainer {
14817 entity_id: self.state.entity_id,
14818 container_id: mode.container_id.clone(),
14819 x: mode.cursor_x,
14820 y: mode.cursor_y,
14821 seq: self.seq,
14822 })
14823 .await?;
14824 self.state.intents_sent += 1;
14825 self.state.relocate_mode = None;
14826 self.state.push_log(format!("Moving {}…", mode.label));
14827 Ok(())
14828 }
14829
14830 pub fn claim_set_preset(&mut self, w: u32, h: u32) {
14831 let Some(mode) = self.state.claim_mode.as_mut() else {
14832 return;
14833 };
14834 mode.width_m = w.max(1);
14835 mode.height_m = h.max(1);
14836 }
14837
14838 pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
14839 let Some(mode) = self.state.claim_mode.as_mut() else {
14840 return;
14841 };
14842 let w = (mode.width_m as i32 + dw).max(1) as u32;
14843 let h = (mode.height_m as i32 + dh).max(1) as u32;
14844 mode.width_m = w;
14845 mode.height_m = h;
14846 }
14847
14848 pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
14850 let Some(mode) = self.state.claim_mode.as_mut() else {
14851 return;
14852 };
14853 let max_x = self.state.world_width_m.max(1.0);
14854 let max_y = self.state.world_height_m.max(1.0);
14855 let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
14856 let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
14857 mode.anchor_x = nx.floor();
14858 mode.anchor_y = ny.floor();
14859 }
14860
14861 pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
14862 if !self.state.is_alive() {
14863 anyhow::bail!("you are dead");
14864 }
14865 let Some(mode) = self.state.claim_mode.clone() else {
14866 anyhow::bail!("not in claim mode");
14867 };
14868 let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
14869 self.state.claim_quote()
14870 else {
14871 anyhow::bail!("cannot quote claim");
14872 };
14873 if !valid {
14874 anyhow::bail!(reason);
14875 }
14876 if !can_afford {
14877 anyhow::bail!(
14878 "not enough copper (need {})",
14879 crate::currency::format_copper(purchase)
14880 );
14881 }
14882 let (x0, y0, x1, y1) = self
14883 .state
14884 .claim_footprint_rect()
14885 .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
14886 let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
14887 self.seq += 1;
14888 self.session
14889 .submit_intent(Intent::BuyPlot {
14890 entity_id: self.state.entity_id,
14891 zone_id: mode.zone_id,
14892 x0,
14893 y0,
14894 x1,
14895 y1,
14896 seq: self.seq,
14897 })
14898 .await?;
14899 self.state.intents_sent += 1;
14900 self.state.claim_mode = None;
14901 self.state.push_log(format!(
14902 "Buying plot for {}",
14903 crate::currency::format_copper(purchase)
14904 ));
14905 Ok(())
14906 }
14907
14908 pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
14909 if !self.state.is_alive() {
14910 anyhow::bail!("you are dead");
14911 }
14912 let zone_id = self
14913 .state
14914 .claim_mode
14915 .as_ref()
14916 .map(|m| m.zone_id.clone())
14917 .or_else(|| {
14918 self.state
14919 .free_property_zone_under_player()
14920 .map(|z| z.id.clone())
14921 })
14922 .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
14923 self.seq += 1;
14924 self.session
14925 .submit_intent(Intent::BuyPlotAllFree {
14926 entity_id: self.state.entity_id,
14927 zone_id,
14928 seq: self.seq,
14929 })
14930 .await?;
14931 self.state.intents_sent += 1;
14932 self.state.claim_mode = None;
14933 self.state.push_log("Claiming largest free plot…");
14934 Ok(())
14935 }
14936
14937 pub async fn confirm_sell_plot_to_crown(&mut self, plot_id: uuid::Uuid) -> anyhow::Result<()> {
14938 if !self.state.is_alive() {
14939 anyhow::bail!("you are dead");
14940 }
14941 self.seq += 1;
14942 self.session
14943 .submit_intent(Intent::SellPlotToCrown {
14944 entity_id: self.state.entity_id,
14945 plot_id,
14946 seq: self.seq,
14947 })
14948 .await?;
14949 self.state.intents_sent += 1;
14950 self.state.sell_plot_confirm = None;
14951 self.state.sell_plot_armed_at = None;
14952 self.state.push_log("Selling plot to the crown…");
14953 Ok(())
14954 }
14955
14956 pub async fn set_plot_farm_public(
14957 &mut self,
14958 plot_id: uuid::Uuid,
14959 public: bool,
14960 public_tax_discount_bps: u32,
14961 ) -> anyhow::Result<()> {
14962 self.seq += 1;
14963 self.session
14964 .submit_intent(Intent::SetPlotFarmPublic {
14965 entity_id: self.state.entity_id,
14966 plot_id,
14967 public,
14968 public_tax_discount_bps,
14969 seq: self.seq,
14970 })
14971 .await?;
14972 self.state.intents_sent += 1;
14973 Ok(())
14974 }
14975
14976 pub async fn plot_farm_allow_upsert(
14977 &mut self,
14978 plot_id: uuid::Uuid,
14979 character_id: Option<uuid::Uuid>,
14980 character_name: String,
14981 tax_discount_bps: u32,
14982 ) -> anyhow::Result<()> {
14983 self.seq += 1;
14984 self.session
14985 .submit_intent(Intent::PlotFarmAllowUpsert {
14986 entity_id: self.state.entity_id,
14987 plot_id,
14988 character_id,
14989 character_name,
14990 tax_discount_bps,
14991 seq: self.seq,
14992 })
14993 .await?;
14994 self.state.intents_sent += 1;
14995 Ok(())
14996 }
14997
14998 pub async fn plot_farm_allow_remove(
14999 &mut self,
15000 plot_id: uuid::Uuid,
15001 character_id: uuid::Uuid,
15002 ) -> anyhow::Result<()> {
15003 self.seq += 1;
15004 self.session
15005 .submit_intent(Intent::PlotFarmAllowRemove {
15006 entity_id: self.state.entity_id,
15007 plot_id,
15008 character_id,
15009 seq: self.seq,
15010 })
15011 .await?;
15012 self.state.intents_sent += 1;
15013 Ok(())
15014 }
15015
15016 pub fn open_farm_access_panel(&mut self) {
15017 let Some(plot) = self.state.my_plot_under_player() else {
15018 self.state
15019 .push_log("Stand on your deed plot to manage farm access");
15020 return;
15021 };
15022 self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
15023 self.state.farm_access_index = 0;
15024 self.state.show_farm_access = true;
15025 }
15026
15027 pub fn close_farm_access_panel(&mut self) {
15028 self.state.show_farm_access = false;
15029 self.state.farm_access_name_draft.clear();
15030 self.state.farm_access_index = 0;
15031 }
15032
15033 pub fn farm_access_move(&mut self, delta: i32) {
15034 let n = self.farm_access_row_count().max(1);
15035 let idx = self.state.farm_access_index as i32 + delta;
15036 self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
15037 }
15038
15039 pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
15040 let Some(plot) = self.state.my_plot_under_player() else {
15041 return vec![FarmAccessRow::PublicToggle];
15042 };
15043 let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
15044 for g in &plot.farm_allow {
15045 rows.push(FarmAccessRow::AllowRemove {
15046 character_id: g.character_id,
15047 label: if g.character_label.trim().is_empty() {
15048 g.character_id.to_string()[..8].to_string()
15049 } else {
15050 g.character_label.clone()
15051 },
15052 tax_discount_bps: g.tax_discount_bps,
15053 });
15054 }
15055 for e in &self.state.entities {
15056 if e.id == self.state.entity_id || e.label.trim().is_empty() {
15057 continue;
15058 }
15059 if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
15060 continue;
15061 }
15062 if self
15063 .state
15064 .npcs
15065 .iter()
15066 .any(|n| n.id == e.label || n.label == e.label)
15067 {
15068 continue;
15069 }
15070 if plot
15071 .farm_allow
15072 .iter()
15073 .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
15074 {
15075 continue;
15076 }
15077 rows.push(FarmAccessRow::NearbyAdd {
15078 name: e.label.clone(),
15079 });
15080 }
15081 rows
15082 }
15083
15084 pub fn farm_access_row_count(&self) -> usize {
15085 self.farm_access_rows().len().max(1)
15086 }
15087
15088 pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
15089 let Some(plot) = self.state.my_plot_under_player().cloned() else {
15090 self.close_farm_access_panel();
15091 return Ok(());
15092 };
15093 let rows = self.farm_access_rows();
15094 let Some(row) = rows.get(self.state.farm_access_index) else {
15095 return Ok(());
15096 };
15097 match row {
15098 FarmAccessRow::PublicToggle => {
15099 self.set_plot_farm_public(
15100 plot.plot_id,
15101 !plot.farm_public,
15102 plot.public_tax_discount_bps,
15103 )
15104 .await
15105 }
15106 FarmAccessRow::PublicDiscount => Ok(()),
15107 FarmAccessRow::AllowRemove { character_id, .. } => {
15108 self.plot_farm_allow_remove(plot.plot_id, *character_id)
15109 .await
15110 }
15111 FarmAccessRow::NearbyAdd { name } => {
15112 let disc = self
15113 .state
15114 .farm_access_discount_bps
15115 .max(plot.public_tax_discount_bps);
15116 self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
15117 .await
15118 }
15119 }
15120 }
15121
15122 pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
15123 let Some(plot) = self.state.my_plot_under_player().cloned() else {
15124 return Ok(());
15125 };
15126 let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
15127 self.state.farm_access_discount_bps = next;
15128 self.state.farm_access_index = 1;
15129 self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
15130 .await
15131 }
15132
15133 pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
15135 if self.state.farmable_plot_under_player().is_none() {
15136 anyhow::bail!("stand on a farmable plot to cultivate");
15137 }
15138 let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
15139 let (px, py) = self.state.player_position();
15140 if self
15141 .state
15142 .terrain_at(px, py)
15143 .is_some_and(|k| k == TerrainKindView::Tilled)
15144 {
15145 anyhow::bail!("already tilled — stand on bare soil and press c");
15146 }
15147 anyhow::bail!("cannot till this cell — move onto soil on your plot");
15148 };
15149 self.cultivate_at(tx, ty).await
15150 }
15151
15152 pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
15154 if self.state.farmable_plot_under_player().is_none() {
15155 anyhow::bail!("stand on a farmable plot to plant");
15156 }
15157 if !self.state.underfoot_free_tilled_plant_slot() {
15158 anyhow::bail!("stand on empty tilled soil and press p");
15159 }
15160 let seeds = self.state.farm_seed_entries();
15161 if seeds.is_empty() {
15162 anyhow::bail!("no seeds in inventory — buy seeds from Eli");
15163 }
15164 if seeds.len() == 1 {
15165 return self.plant_seeds(seeds[0].0.clone(), 1).await;
15166 }
15167 self.open_plant_menu();
15168 Ok(())
15169 }
15170
15171 pub fn open_plot_build_menu(&mut self) -> anyhow::Result<()> {
15173 let Some(plot) = self.state.my_plot_under_player() else {
15174 anyhow::bail!("stand on your plot to build");
15175 };
15176 if plot.building_id.is_some() {
15177 anyhow::bail!("this plot already has a building");
15178 }
15179 let building_now = self
15180 .state
15181 .timed_channel
15182 .as_ref()
15183 .is_some_and(|c| c.channel == flatland_protocol::TimedChannelKind::Build);
15184 if !building_now && self.state.building_materials.is_empty() {
15185 anyhow::bail!("no building materials loaded — wait a moment and try again");
15186 }
15187 self.state.show_plot_build_menu = true;
15188 self.state.show_craft_menu = false;
15189 self.state.show_shop_menu = false;
15190 self.state.shop_catalog = None;
15191 self.state.show_stats = false;
15192 self.state.show_inventory_menu = false;
15193 self.state.plot_build_focus_wall = true;
15194 let walls = self.state.plot_build_wall_options().len();
15195 let roofs = self.state.plot_build_roof_options().len();
15196 if walls > 0 {
15197 self.state.plot_build_wall_index = self.state.plot_build_wall_index.min(walls - 1);
15198 } else {
15199 self.state.plot_build_wall_index = 0;
15200 }
15201 if roofs > 0 {
15202 self.state.plot_build_roof_index = self.state.plot_build_roof_index.min(roofs - 1);
15203 } else {
15204 self.state.plot_build_roof_index = 0;
15205 }
15206 Ok(())
15207 }
15208
15209 pub fn close_plot_build_menu(&mut self) {
15210 self.state.show_plot_build_menu = false;
15211 }
15212
15213 pub fn plot_build_menu_move(&mut self, delta: i32) {
15214 let walls = self.state.plot_build_wall_options();
15215 let roofs = self.state.plot_build_roof_options();
15216 if self.state.plot_build_focus_wall {
15217 if walls.is_empty() {
15218 return;
15219 }
15220 let n = walls.len() as i32;
15221 let cur = self.state.plot_build_wall_index as i32;
15222 self.state.plot_build_wall_index = ((cur + delta).rem_euclid(n)) as usize;
15223 } else {
15224 if roofs.is_empty() {
15225 return;
15226 }
15227 let n = roofs.len() as i32;
15228 let cur = self.state.plot_build_roof_index as i32;
15229 self.state.plot_build_roof_index = ((cur + delta).rem_euclid(n)) as usize;
15230 }
15231 }
15232
15233 pub fn plot_build_menu_toggle_focus(&mut self) {
15234 self.state.plot_build_focus_wall = !self.state.plot_build_focus_wall;
15235 }
15236
15237 pub async fn plot_build_menu_confirm(&mut self) -> anyhow::Result<()> {
15239 let wall = self
15240 .state
15241 .plot_build_selected_wall()
15242 .ok_or_else(|| anyhow::anyhow!("pick a wall material"))?
15243 .id
15244 .clone();
15245 let roof = self
15246 .state
15247 .plot_build_selected_roof()
15248 .ok_or_else(|| anyhow::anyhow!("pick a roof material"))?
15249 .id
15250 .clone();
15251 self.start_plot_build(&wall, &roof).await
15253 }
15254
15255 pub async fn plot_build_menu_cancel_build(&mut self) -> anyhow::Result<()> {
15257 self.seq += 1;
15258 self.session
15259 .submit_intent(Intent::CancelPlotBuild {
15260 entity_id: self.state.entity_id,
15261 seq: self.seq,
15262 })
15263 .await?;
15264 self.state.intents_sent += 1;
15265 Ok(())
15266 }
15267
15268 pub async fn start_plot_build(
15270 &mut self,
15271 wall_material_id: &str,
15272 roof_material_id: &str,
15273 ) -> anyhow::Result<()> {
15274 let Some(plot) = self.state.my_plot_under_player() else {
15275 anyhow::bail!("stand on your plot to build");
15276 };
15277 if plot.building_id.is_some() {
15278 anyhow::bail!("this plot already has a building");
15279 }
15280 let plot_id = plot.plot_id;
15281 self.seq += 1;
15282 self.session
15283 .submit_intent(Intent::StartPlotBuild {
15284 entity_id: self.state.entity_id,
15285 plot_id,
15286 wall_material_id: wall_material_id.to_string(),
15287 roof_material_id: roof_material_id.to_string(),
15288 seq: self.seq,
15289 })
15290 .await?;
15291 self.state.intents_sent += 1;
15292 Ok(())
15293 }
15294
15295 pub async fn toggle_nearby_door_lock(&mut self) -> anyhow::Result<()> {
15297 let (px, py) = self.state.player_position();
15298 let mut best: Option<(f32, String, bool)> = None;
15299 for d in &self.state.doors {
15300 if d.lock_id.is_none() {
15301 continue;
15302 }
15303 let dist = (d.x - px).hypot(d.y - py);
15304 if dist > DOOR_INTERACTION_RADIUS_M {
15305 continue;
15306 }
15307 if best.as_ref().is_none_or(|(bd, _, _)| dist < *bd) {
15308 best = Some((dist, d.id.clone(), d.locked));
15309 }
15310 }
15311 let Some((_, door_id, locked_now)) = best else {
15312 anyhow::bail!("no lockable door nearby");
15313 };
15314 let locked = !locked_now;
15315 self.seq += 1;
15316 self.session
15317 .submit_intent(Intent::SetDoorLocked {
15318 entity_id: self.state.entity_id,
15319 door_id,
15320 locked,
15321 seq: self.seq,
15322 })
15323 .await?;
15324 self.state.intents_sent += 1;
15325 Ok(())
15326 }
15327
15328 pub async fn enter_nearby_open_door(&mut self) -> anyhow::Result<()> {
15330 if !self.state.is_alive() {
15331 anyhow::bail!("you are dead");
15332 }
15333 if self.state.effective_inside_building().is_some() {
15334 anyhow::bail!("already inside");
15335 }
15336 let (px, py) = self.state.player_position();
15337 let mut best: Option<(f32, String)> = None;
15338 for d in &self.state.doors {
15339 if !d.open || d.locked {
15340 continue;
15341 }
15342 let player_house = self
15343 .state
15344 .buildings
15345 .iter()
15346 .find(|b| b.id == d.building_id)
15347 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
15348 if !player_house {
15349 continue;
15350 }
15351 let dist = (d.x - px).hypot(d.y - py);
15352 if dist > DOOR_INTERACTION_RADIUS_M {
15353 continue;
15354 }
15355 if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
15356 best = Some((dist, d.id.clone()));
15357 }
15358 }
15359 let Some((_, door_id)) = best else {
15360 anyhow::bail!("no open house door nearby — open with f first");
15361 };
15362 self.seq += 1;
15363 self.session
15364 .submit_intent(Intent::EnterBuildingDoor {
15365 entity_id: self.state.entity_id,
15366 door_id,
15367 seq: self.seq,
15368 })
15369 .await?;
15370 self.state.intents_sent += 1;
15371 Ok(())
15372 }
15373
15374 pub async fn exit_nearby_building_door(&mut self) -> anyhow::Result<()> {
15377 if !self.state.is_alive() {
15378 anyhow::bail!("you are dead");
15379 }
15380 let Some(bid) = self.state.effective_inside_building() else {
15381 anyhow::bail!("not inside a building");
15382 };
15383 let (px, py) = self.state.player_position();
15384 let mut best: Option<(f32, String)> = None;
15385 for d in &self.state.doors {
15386 if d.building_id != bid || d.portal.is_none() {
15387 continue;
15388 }
15389 let player_house = self
15390 .state
15391 .buildings
15392 .iter()
15393 .find(|b| b.id == d.building_id)
15394 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
15395 if !player_house {
15396 continue;
15397 }
15398 let dist = (d.x - px).hypot(d.y - py);
15399 if dist > 1.5 {
15400 continue;
15401 }
15402 if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
15403 best = Some((dist, d.id.clone()));
15404 }
15405 }
15406 let Some((_, door_id)) = best else {
15407 anyhow::bail!("stand by the door to exit");
15408 };
15409 self.seq += 1;
15410 self.session
15411 .submit_intent(Intent::ExitBuildingDoor {
15412 entity_id: self.state.entity_id,
15413 door_id,
15414 seq: self.seq,
15415 })
15416 .await?;
15417 self.state.intents_sent += 1;
15418 Ok(())
15419 }
15420
15421 pub async fn confirm_interior_edit(
15423 &mut self,
15424 building_id: String,
15425 rooms: Vec<flatland_protocol::InteriorRoomEdit>,
15426 room_doors: Vec<flatland_protocol::InteriorRoomDoorEdit>,
15427 ) -> anyhow::Result<()> {
15428 self.seq += 1;
15429 self.session
15430 .submit_intent(Intent::ConfirmInteriorEdit {
15431 entity_id: self.state.entity_id,
15432 building_id,
15433 rooms,
15434 room_doors,
15435 seq: self.seq,
15436 })
15437 .await?;
15438 self.state.intents_sent += 1;
15439 Ok(())
15440 }
15441
15442 pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
15443 if !self.state.is_alive() {
15444 anyhow::bail!("you are dead");
15445 }
15446 self.seq += 1;
15447 self.session
15448 .submit_intent(Intent::Cultivate {
15449 entity_id: self.state.entity_id,
15450 x,
15451 y,
15452 seq: self.seq,
15453 })
15454 .await?;
15455 self.state.intents_sent += 1;
15456 Ok(())
15457 }
15458
15459 pub async fn plant_seeds(
15460 &mut self,
15461 seed_template_id: String,
15462 quantity: u32,
15463 ) -> anyhow::Result<()> {
15464 if !self.state.is_alive() {
15465 anyhow::bail!("you are dead");
15466 }
15467 self.seq += 1;
15468 self.session
15469 .submit_intent(Intent::PlantSeeds {
15470 entity_id: self.state.entity_id,
15471 seed_template_id: seed_template_id.clone(),
15472 quantity,
15473 seq: self.seq,
15474 })
15475 .await?;
15476 self.state.intents_sent += 1;
15477 self.state
15478 .push_log(format!("Planting {quantity}× {seed_template_id}…"));
15479 Ok(())
15480 }
15481
15482 pub fn open_plant_menu(&mut self) {
15483 if self.state.farm_seed_entries().is_empty() {
15484 self.state.push_log("No seeds in inventory to plant");
15485 return;
15486 }
15487 self.state.show_plant_menu = true;
15488 self.state.plant_menu_index = 0;
15489 self.state.plant_quantity = 1;
15490 self.state.clamp_plant_menu();
15491 }
15492
15493 pub fn close_plant_menu(&mut self) {
15494 self.state.show_plant_menu = false;
15495 }
15496
15497 pub fn plant_menu_move(&mut self, delta: i32) {
15498 let n = self.state.farm_seed_entries().len();
15499 if n == 0 {
15500 return;
15501 }
15502 let idx = self.state.plant_menu_index as i32 + delta;
15503 self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
15504 self.state.clamp_plant_menu();
15505 }
15506
15507 pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
15508 let next = self.state.plant_quantity as i32 + delta;
15509 self.state.plant_quantity = next.max(1) as u32;
15510 self.state.clamp_plant_menu();
15511 }
15512
15513 pub fn plant_menu_set_quantity_max(&mut self) {
15514 if let Some((_, max, _)) = self.state.plant_menu_selection() {
15515 self.state.plant_quantity = max;
15516 }
15517 self.state.clamp_plant_menu();
15518 }
15519
15520 pub fn plant_menu_set_quantity_min(&mut self) {
15521 self.state.plant_quantity = 1;
15522 self.state.clamp_plant_menu();
15523 }
15524
15525 pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
15526 let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
15527 self.close_plant_menu();
15528 anyhow::bail!("no seeds to plant");
15529 };
15530 self.close_plant_menu();
15531 self.plant_seeds(seed, qty).await?;
15532 self.state.push_log(format!("Planted {qty}× {label}"));
15533 Ok(())
15534 }
15535
15536 pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
15539 if !self.state.is_alive() {
15540 anyhow::bail!("you are dead");
15541 }
15542 let binding = self
15543 .state
15544 .hotbar_ability(slot)
15545 .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
15546 .to_string();
15547 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
15548 let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
15549 if qty == 0 {
15550 anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
15551 }
15552 return self.use_item(template_id).await;
15553 }
15554 let ability_id = binding;
15555 if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
15556 return self
15557 .cast_ability(&ability_id, Some(self.state.entity_id))
15558 .await;
15559 }
15560 let is_heal = ability_id == "heal_touch"
15561 || self
15562 .state
15563 .ability_meta
15564 .get(&ability_id)
15565 .map(|meta| meta.is_heal)
15566 .unwrap_or(false);
15567 let target = if is_heal {
15568 Some(
15569 self.state
15570 .target_for_slot(2)
15571 .unwrap_or(self.state.entity_id),
15572 )
15573 } else {
15574 self.state
15575 .target_for_slot(1)
15576 .or_else(|| self.state.target_for_slot(2))
15577 };
15578 let Some(target_id) = target else {
15579 anyhow::bail!("no target — Tab to select, then press the hotbar key");
15580 };
15581 self.cast_ability(&ability_id, Some(target_id)).await
15582 }
15583
15584 pub async fn set_hotbar_slot(
15587 &mut self,
15588 slot: u8,
15589 ability_id: Option<&str>,
15590 ) -> anyhow::Result<()> {
15591 if !self.state.is_alive() {
15592 anyhow::bail!("you are dead");
15593 }
15594 if !(1..=9).contains(&slot) {
15595 anyhow::bail!("hotbar slot must be 1–9");
15596 }
15597 let ability_id = ability_id
15598 .map(str::trim)
15599 .filter(|id| !id.is_empty())
15600 .map(str::to_string);
15601 self.seq += 1;
15602 self.session
15603 .submit_intent(Intent::SetHotbarSlot {
15604 entity_id: self.state.entity_id,
15605 slot,
15606 ability_id: ability_id.clone(),
15607 seq: self.seq,
15608 })
15609 .await?;
15610 self.state.intents_sent += 1;
15611 let idx = (slot - 1) as usize;
15612 if self.state.hotbar.len() < 9 {
15613 self.state.hotbar.resize(9, None);
15614 }
15615 if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
15616 *slot_mut = ability_id.clone();
15617 }
15618 match ability_id {
15619 Some(id) => {
15620 let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
15621 format!("use {tid}")
15622 } else {
15623 id
15624 };
15625 self.state.push_log(format!("Hotbar {slot} → {label}"))
15626 }
15627 None => self.state.push_log(format!("Hotbar {slot} cleared")),
15628 }
15629 Ok(())
15630 }
15631
15632 pub fn npc_verb_options(&self) -> Vec<NpcVerbChoice> {
15633 self.state.npc_verb_options()
15634 }
15635
15636 pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
15637 let Some(npc_id) = self.state.npc_verb_target.clone() else {
15638 return Ok(());
15639 };
15640 let options = self.npc_verb_options();
15641 let choice = options
15642 .get(self.state.npc_verb_index)
15643 .cloned()
15644 .unwrap_or_else(GameState::talk_choice);
15645 match choice.action {
15646 NpcVerbAction::QuestGive { quest_id } => {
15647 self.submit_npc_quest_turn_in(&npc_id, Some(&quest_id))
15648 .await?;
15649 self.state.show_npc_verb_menu = false;
15650 }
15651 NpcVerbAction::Talk => {
15652 self.open_npc_talk(&npc_id, None).await?;
15653 }
15654 NpcVerbAction::QuestTalk { quest_id } => {
15655 self.open_npc_talk(&npc_id, Some(&quest_id)).await?;
15656 }
15657 NpcVerbAction::Trade | NpcVerbAction::Bank | NpcVerbAction::Storage | NpcVerbAction::Market => {
15658 self.seq += 1;
15659 self.session
15660 .submit_intent(Intent::Interact {
15661 entity_id: self.state.entity_id,
15662 target_id: npc_id,
15663 seq: self.seq,
15664 })
15665 .await?;
15666 self.state.intents_sent += 1;
15667 }
15668 }
15669 Ok(())
15670 }
15671
15672 async fn open_npc_talk(&mut self, npc_id: &str, quest_id: Option<&str>) -> anyhow::Result<()> {
15673 self.seq += 1;
15674 self.session
15675 .submit_intent(Intent::NpcTalkOpen {
15676 entity_id: self.state.entity_id,
15677 npc_id: npc_id.to_string(),
15678 quest_id: quest_id.map(str::to_string),
15679 seq: self.seq,
15680 })
15681 .await?;
15682 self.state.intents_sent += 1;
15683 Ok(())
15684 }
15685
15686 async fn submit_npc_quest_turn_in(
15687 &mut self,
15688 npc_id: &str,
15689 quest_id: Option<&str>,
15690 ) -> anyhow::Result<()> {
15691 let catalog_ref = self.state.npc_quest_catalog_ref(npc_id);
15692 let pending: Vec<(String, u32, String)> = self
15693 .state
15694 .quest_log
15695 .iter()
15696 .filter(|q| {
15697 q.status == flatland_protocol::QuestStatusView::Active
15698 && quest_id.is_none_or(|id| q.quest_id == id)
15699 })
15700 .flat_map(|q| q.objectives.iter())
15701 .filter(|o| {
15702 !o.done
15703 && o.kind == "give_item"
15704 && o.npc_ref.as_deref() == Some(catalog_ref.as_str())
15705 })
15706 .filter_map(|o| {
15707 let template = o.item_template.clone()?;
15708 let remaining = o.required.saturating_sub(o.current);
15709 if remaining == 0 {
15710 return None;
15711 }
15712 Some((template, remaining, o.label.clone()))
15713 })
15714 .collect();
15715 if pending.is_empty() {
15716 self.state.push_log("Nothing to turn in here.");
15717 return Ok(());
15718 }
15719 let mut sent = 0u32;
15720 for (template, remaining, label) in pending {
15721 let held = self.state.count_inventory_template(&template);
15722 let qty = remaining.min(held);
15723 if qty == 0 {
15724 self.state.push_log(format!("Need {label}"));
15725 continue;
15726 }
15727 self.seq += 1;
15728 self.session
15729 .submit_intent(Intent::QuestGiveItem {
15730 entity_id: self.state.entity_id,
15731 npc_id: npc_id.to_string(),
15732 template_id: template,
15733 quantity: qty,
15734 seq: self.seq,
15735 })
15736 .await?;
15737 self.state.intents_sent += 1;
15738 sent += 1;
15739 }
15740 if sent > 0 {
15741 self.state.push_log("Turning in quest items.");
15742 }
15743 Ok(())
15744 }
15745
15746 pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
15747 let Some(chat) = self.state.npc_chat.clone() else {
15748 return Ok(());
15749 };
15750 let message = chat.input.trim().to_string();
15751 if message.is_empty() || chat.pending {
15752 return Ok(());
15753 }
15754 if let Some(c) = self.state.npc_chat.as_mut() {
15755 c.lines.push(format!("You: {message}"));
15756 c.input.clear();
15757 c.pending = true;
15758 }
15759 self.seq += 1;
15760 self.session
15761 .submit_intent(Intent::NpcTalkSay {
15762 entity_id: self.state.entity_id,
15763 npc_id: chat.npc_id,
15764 message,
15765 seq: self.seq,
15766 })
15767 .await?;
15768 self.state.intents_sent += 1;
15769 Ok(())
15770 }
15771
15772 pub async fn npc_talk_topic(&mut self, index: usize) -> anyhow::Result<()> {
15773 let topic = self
15774 .state
15775 .npc_chat
15776 .as_ref()
15777 .and_then(|c| c.suggested_topics.get(index))
15778 .cloned();
15779 let Some(topic) = topic else {
15780 return Ok(());
15781 };
15782 if let Some(c) = self.state.npc_chat.as_mut() {
15783 if c.pending {
15784 return Ok(());
15785 }
15786 c.input = topic;
15787 }
15788 self.npc_talk_send().await
15789 }
15790
15791 pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
15792 let return_to_verbs = self.state.npc_verb_target.is_some();
15793 let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
15794 self.state.show_npc_chat = false;
15795 if return_to_verbs {
15796 self.state.show_npc_verb_menu = true;
15797 }
15798 return Ok(());
15799 };
15800 self.seq += 1;
15801 self.session
15802 .submit_intent(Intent::NpcTalkClose {
15803 entity_id: self.state.entity_id,
15804 npc_id,
15805 seq: self.seq,
15806 })
15807 .await?;
15808 self.state.intents_sent += 1;
15809 self.state.show_npc_chat = false;
15810 self.state.npc_chat = None;
15811 if return_to_verbs {
15812 self.state.show_npc_verb_menu = true;
15813 self.state.npc_verb_notice = None;
15814 }
15815 Ok(())
15816 }
15817
15818 pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
15820 if self.state.show_quest_offer
15821 && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
15822 {
15823 self.quest_offer_decline();
15824 return Ok(());
15825 }
15826 if self.state.show_npc_chat {
15827 return self.npc_talk_close().await;
15828 }
15829 if self.state.show_shop_menu {
15830 return self.back_from_shop_menu().await;
15831 }
15832 if self.state.bank_panel.is_some() {
15833 if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
15834 self.bank_transfer_back();
15835 return Ok(());
15836 }
15837 return self.close_bank_panel().await;
15838 }
15839 if self.state.storage_panel.is_some() {
15840 if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
15841 self.storage_ui_back();
15842 return Ok(());
15843 }
15844 return self.close_storage_panel().await;
15845 }
15846 if self.state.market_panel.is_some() {
15847 if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
15848 self.market_ui_back();
15849 return Ok(());
15850 }
15851 if self.state.market_buy_confirm.is_some() {
15852 self.state.market_buy_confirm = None;
15853 return Ok(());
15854 }
15855 return self.close_market_panel().await;
15856 }
15857 if self.state.show_npc_verb_menu {
15858 self.state.show_npc_verb_menu = false;
15859 self.state.npc_verb_target = None;
15860 }
15861 Ok(())
15862 }
15863
15864 pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
15865 self.seq += 1;
15866 self.session
15867 .submit_intent(Intent::TestDamage {
15868 entity_id: self.state.entity_id,
15869 amount,
15870 seq: self.seq,
15871 })
15872 .await?;
15873 self.state.intents_sent += 1;
15874 Ok(())
15875 }
15876
15877 pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
15878 self.cycle_combat_target_slot(1, reverse).await
15879 }
15880
15881 pub async fn cycle_combat_target_slot(
15882 &mut self,
15883 slot_index: u8,
15884 reverse: bool,
15885 ) -> anyhow::Result<()> {
15886 if !self.state.is_alive() {
15887 anyhow::bail!("you are dead");
15888 }
15889 let candidates = self.state.candidates_for_slot(slot_index);
15890 if candidates.is_empty() {
15891 anyhow::bail!("no targets nearby");
15892 }
15893 let current = self.state.target_for_slot(slot_index);
15894 let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
15895 let next_idx = match idx {
15896 None => 0,
15897 Some(i) if reverse => {
15898 if i == 0 {
15899 candidates.len() - 1
15900 } else {
15901 i - 1
15902 }
15903 }
15904 Some(i) => (i + 1) % candidates.len(),
15905 };
15906 if idx == Some(next_idx) && candidates.len() == 1 {
15907 self.clear_combat_target_slot(slot_index).await?;
15908 return Ok(());
15909 }
15910 let (target_id, label) = candidates[next_idx].clone();
15911 self.set_combat_target_slot(slot_index, target_id, &label)
15912 .await
15913 }
15914
15915 pub async fn set_combat_target_slot(
15916 &mut self,
15917 slot_index: u8,
15918 target_id: EntityId,
15919 label: &str,
15920 ) -> anyhow::Result<()> {
15921 if !self.state.is_alive() {
15922 anyhow::bail!("you are dead");
15923 }
15924 self.seq += 1;
15925 self.session
15926 .submit_intent(Intent::SetTargetSlot {
15927 entity_id: self.state.entity_id,
15928 slot_index,
15929 target_id,
15930 seq: self.seq,
15931 })
15932 .await?;
15933 self.state.intents_sent += 1;
15934 if slot_index == 1 {
15935 self.state.combat_target = Some(target_id);
15936 self.state.combat_target_label = Some(label.to_string());
15937 }
15938 self.state
15939 .push_log(format!("Slot {slot_index} target: {label}"));
15940 Ok(())
15941 }
15942
15943 pub async fn set_combat_target(
15944 &mut self,
15945 target_id: EntityId,
15946 label: &str,
15947 ) -> anyhow::Result<()> {
15948 self.set_combat_target_slot(1, target_id, label).await
15949 }
15950
15951 pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
15952 if slot_index == 1 && self.state.combat_target.is_none() {
15953 return Ok(());
15954 }
15955 self.seq += 1;
15956 self.session
15957 .submit_intent(Intent::ClearTargetSlot {
15958 entity_id: self.state.entity_id,
15959 slot_index,
15960 seq: self.seq,
15961 })
15962 .await?;
15963 if slot_index == 1 {
15964 self.state.combat_target = None;
15965 self.state.combat_target_label = None;
15966 }
15967 self.state.intents_sent += 1;
15968 self.state
15969 .push_log(format!("Slot {slot_index} target cleared"));
15970 Ok(())
15971 }
15972
15973 pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
15974 self.clear_combat_target_slot(1).await
15975 }
15976
15977 pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
15978 if !self.state.is_alive() {
15979 anyhow::bail!("you are dead");
15980 }
15981 self.seq += 1;
15982 self.session
15983 .submit_intent(Intent::AdvanceRotation {
15984 entity_id: self.state.entity_id,
15985 slot_index,
15986 seq: self.seq,
15987 })
15988 .await?;
15989 self.state.intents_sent += 1;
15990 Ok(())
15991 }
15992
15993 pub async fn assign_slot_preset(
15994 &mut self,
15995 slot_index: u8,
15996 preset_id: &str,
15997 ) -> anyhow::Result<()> {
15998 if !self.state.is_alive() {
15999 anyhow::bail!("you are dead");
16000 }
16001 self.seq += 1;
16002 self.session
16003 .submit_intent(Intent::AssignSlotPreset {
16004 entity_id: self.state.entity_id,
16005 slot_index,
16006 preset_id: preset_id.to_string(),
16007 seq: self.seq,
16008 })
16009 .await?;
16010 self.state.intents_sent += 1;
16011 if let Some(slot) = self
16012 .state
16013 .combat_slots
16014 .iter_mut()
16015 .find(|s| s.slot_index == slot_index)
16016 {
16017 slot.preset_id = Some(preset_id.to_string());
16018 if let Some(preset) = self
16019 .state
16020 .rotation_presets
16021 .iter()
16022 .find(|p| p.id == preset_id)
16023 {
16024 slot.preset_label = Some(preset.label.clone());
16025 slot.rotation = preset.abilities.clone();
16026 slot.rotation_index = 0;
16027 }
16028 }
16029 self.state
16030 .push_log(format!("T{slot_index} loadout → {preset_id}"));
16031 Ok(())
16032 }
16033
16034 pub async fn cast_ability(
16035 &mut self,
16036 ability_id: &str,
16037 target_id: Option<EntityId>,
16038 ) -> anyhow::Result<()> {
16039 if !self.state.is_alive() {
16040 anyhow::bail!("you are dead");
16041 }
16042 let allows_ground = self.state.ability_allows_ground(ability_id);
16043 let requires_ground = self.state.ability_requires_ground(ability_id);
16044 if requires_ground && self.state.ground_target.is_none() {
16045 anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
16046 }
16047 let (resolved_target_id, target_point) = if allows_ground {
16048 if let Some((x, y, z)) = self.state.ground_target {
16049 (
16050 target_id.unwrap_or(self.state.entity_id),
16051 Some(flatland_protocol::AimPoint { x, y, z }),
16052 )
16053 } else {
16054 (
16055 target_id
16056 .or_else(|| self.state.target_for_slot(2))
16057 .or_else(|| self.state.target_for_slot(1))
16058 .unwrap_or(self.state.entity_id),
16059 None,
16060 )
16061 }
16062 } else {
16063 (
16064 target_id
16065 .or_else(|| self.state.target_for_slot(2))
16066 .or_else(|| self.state.target_for_slot(1))
16067 .unwrap_or(self.state.entity_id),
16068 None,
16069 )
16070 };
16071 self.seq += 1;
16072 self.session
16073 .submit_intent(Intent::Cast {
16074 entity_id: self.state.entity_id,
16075 ability_id: ability_id.to_string(),
16076 target_id: resolved_target_id,
16077 target_point,
16078 seq: self.seq,
16079 })
16080 .await?;
16081 self.state.intents_sent += 1;
16082 match target_point {
16083 Some(point) => self.state.push_log(format!(
16084 "Cast {ability_id} → ({:.1}, {:.1})",
16085 point.x, point.y
16086 )),
16087 None => self
16088 .state
16089 .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
16090 }
16091 Ok(())
16092 }
16093
16094 pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
16095 self.seq += 1;
16096 self.session
16097 .submit_intent(Intent::UpsertRotationPreset {
16098 entity_id: self.state.entity_id,
16099 preset: preset.clone(),
16100 seq: self.seq,
16101 })
16102 .await?;
16103 self.state.intents_sent += 1;
16104 if let Some(existing) = self
16105 .state
16106 .rotation_presets
16107 .iter_mut()
16108 .find(|p| p.id == preset.id)
16109 {
16110 *existing = preset.clone();
16111 } else {
16112 self.state.rotation_presets.push(preset.clone());
16113 }
16114 for slot in &mut self.state.combat_slots {
16115 if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
16116 slot.preset_label = Some(preset.label.clone());
16117 slot.rotation = preset.abilities.clone();
16118 }
16119 }
16120 self.state
16121 .push_log(format!("Saved rotation: {}", preset.label));
16122 Ok(())
16123 }
16124
16125 pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
16126 self.seq += 1;
16127 self.session
16128 .submit_intent(Intent::DeleteRotationPreset {
16129 entity_id: self.state.entity_id,
16130 preset_id: preset_id.to_string(),
16131 seq: self.seq,
16132 })
16133 .await?;
16134 self.state.intents_sent += 1;
16135 self.state.rotation_presets.retain(|p| p.id != preset_id);
16136 for slot in &mut self.state.combat_slots {
16137 if slot.preset_id.as_deref() == Some(preset_id) {
16138 slot.preset_id = None;
16139 slot.preset_label = None;
16140 slot.rotation.clear();
16141 slot.rotation_index = 0;
16142 }
16143 }
16144 self.state
16145 .push_log(format!("Deleted rotation: {preset_id}"));
16146 Ok(())
16147 }
16148
16149 pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
16150 if !self.state.is_alive() {
16151 anyhow::bail!("you are dead");
16152 }
16153 let enabled = !self
16154 .state
16155 .combat_slots
16156 .iter()
16157 .find(|s| s.slot_index == slot_index)
16158 .map(|s| s.auto_enabled)
16159 .unwrap_or(false);
16160 self.seq += 1;
16161 self.session
16162 .submit_intent(Intent::SetAutoAttack {
16163 entity_id: self.state.entity_id,
16164 slot_index,
16165 enabled,
16166 seq: self.seq,
16167 })
16168 .await?;
16169 if slot_index == 1 {
16170 self.state.auto_attack = enabled;
16171 }
16172 self.state.intents_sent += 1;
16173 self.state.push_log(format!(
16174 "T{slot_index} auto {}",
16175 if enabled { "ON" } else { "OFF" }
16176 ));
16177 Ok(())
16178 }
16179
16180 pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
16181 if !self.state.connected {
16182 anyhow::bail!("not connected");
16183 }
16184 if !self.state.is_alive() {
16185 anyhow::bail!("you are dead");
16186 }
16187 let (px, py) = self.state.player_position();
16188 if self
16189 .state
16190 .ground_drops
16191 .iter()
16192 .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
16193 {
16194 anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
16195 }
16196 self.seq += 1;
16197 self.session
16198 .submit_intent(Intent::Pickup {
16199 entity_id: self.state.entity_id,
16200 drop_id: None,
16201 seq: self.seq,
16202 })
16203 .await?;
16204 self.state.intents_sent += 1;
16205 self.state.push_audio(crate::social::AudioCue::LootPickup);
16206 Ok(())
16207 }
16208
16209 pub async fn dodge(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
16210 if !self.state.is_alive() {
16211 anyhow::bail!("you are dead");
16212 }
16213 self.seq += 1;
16215 self.session
16216 .submit_intent(Intent::Dodge {
16217 entity_id: self.state.entity_id,
16218 forward,
16219 strafe,
16220 seq: self.seq,
16221 })
16222 .await?;
16223 self.state.intents_sent += 1;
16224 self.state.push_log("Dodge!");
16225 self.state.push_audio(crate::social::AudioCue::CombatDodge);
16226 Ok(())
16227 }
16228
16229 pub async fn lunge(&mut self) -> anyhow::Result<()> {
16230 if !self.state.is_alive() {
16231 anyhow::bail!("you are dead");
16232 }
16233 let (forward, strafe) = self.last_move_axes();
16234 self.seq += 1;
16235 self.session
16236 .submit_intent(Intent::Lunge {
16237 entity_id: self.state.entity_id,
16238 forward,
16239 strafe,
16240 seq: self.seq,
16241 })
16242 .await?;
16243 self.state.intents_sent += 1;
16244 self.state.push_log("Lunge!");
16245 Ok(())
16246 }
16247
16248 pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
16249 if !self.state.is_alive() {
16250 anyhow::bail!("you are dead");
16251 }
16252 self.seq += 1;
16253 self.session
16254 .submit_intent(Intent::DirectionalJump {
16255 entity_id: self.state.entity_id,
16256 forward,
16257 strafe,
16258 seq: self.seq,
16259 })
16260 .await?;
16261 self.state.intents_sent += 1;
16262 self.state.push_log("Jump!");
16263 Ok(())
16264 }
16265
16266 pub fn last_move_axes(&self) -> (f32, f32) {
16268 (self.last_move_forward, self.last_move_strafe)
16269 }
16270
16271 pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
16272 if !self.state.is_alive() {
16273 anyhow::bail!("you are dead");
16274 }
16275 self.seq += 1;
16276 self.session
16277 .submit_intent(Intent::Block {
16278 entity_id: self.state.entity_id,
16279 enabled,
16280 seq: self.seq,
16281 })
16282 .await?;
16283 self.state.intents_sent += 1;
16284 if enabled {
16285 self.state.push_log("Blocking");
16286 self.state.push_audio(crate::social::AudioCue::CombatBlock);
16287 }
16288 Ok(())
16289 }
16290
16291 pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
16292 if !self.state.is_alive() {
16293 anyhow::bail!("you are dead");
16294 }
16295 self.seq += 1;
16296 self.session
16297 .submit_intent(Intent::EquipMainhand {
16298 entity_id: self.state.entity_id,
16299 template_id,
16300 instance_id: None,
16301 seq: self.seq,
16302 })
16303 .await?;
16304 self.state.intents_sent += 1;
16305 Ok(())
16306 }
16307
16308 pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
16310 let idx = self.state.equip_menu_index;
16311 let slots = equip_paperdoll_rows(&self.state);
16312 let Some(row) = slots.get(idx) else {
16313 return Ok(());
16314 };
16315 match row {
16316 EquipPaperdollRow::Body { slot, filled } => {
16317 if *filled {
16318 self.equip_worn(*slot, None).await
16319 } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
16320 self.equip_worn(*slot, Some(inst)).await
16321 } else {
16322 self.state
16323 .push_log(format!("No item for {}", body_slot_label(*slot)));
16324 Ok(())
16325 }
16326 }
16327 EquipPaperdollRow::Mainhand { filled } => {
16328 if *filled {
16329 self.unequip_mainhand().await
16330 } else if let Some(tid) = first_inventory_weapon(&self.state) {
16331 self.equip_mainhand(Some(tid)).await
16332 } else {
16333 self.state.push_log("No weapon in inventory".to_string());
16334 Ok(())
16335 }
16336 }
16337 EquipPaperdollRow::Offhand { filled, locked } => {
16338 if *locked {
16339 self.state
16340 .push_log("Offhand locked — two-handed weapon equipped".to_string());
16341 Ok(())
16342 } else if *filled {
16343 self.unequip_offhand().await
16344 } else if let Some(tid) = first_inventory_offhand(&self.state) {
16345 self.equip_offhand(Some(tid)).await
16346 } else {
16347 self.state
16348 .push_log("No offhand item in inventory".to_string());
16349 Ok(())
16350 }
16351 }
16352 }
16353 }
16354
16355 pub async fn say(
16356 &mut self,
16357 channel: flatland_protocol::ChatChannel,
16358 text: &str,
16359 ) -> anyhow::Result<()> {
16360 self.say_to(channel, text, None).await
16361 }
16362
16363 pub async fn say_to(
16364 &mut self,
16365 channel: flatland_protocol::ChatChannel,
16366 text: &str,
16367 to_entity: Option<EntityId>,
16368 ) -> anyhow::Result<()> {
16369 self.seq += 1;
16370 self.session
16371 .submit_intent(Intent::Say {
16372 entity_id: self.state.entity_id,
16373 channel,
16374 text: text.to_string(),
16375 to_entity,
16376 seq: self.seq,
16377 })
16378 .await?;
16379 self.state.intents_sent += 1;
16380 Ok(())
16381 }
16382
16383 pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
16384 let Some(peer) = self.state.player_verbs.target_entity else {
16385 return Ok(());
16386 };
16387 let label = self.state.player_verbs.target_label.clone();
16388 let choice = crate::social::PlayerVerbState::options()
16389 .get(self.state.player_verbs.index)
16390 .copied()
16391 .unwrap_or("Whisper");
16392 self.state.player_verbs.close();
16393 match choice {
16394 "Trade" => {
16395 self.seq += 1;
16398 self.session
16399 .submit_intent(Intent::TradeRequest {
16400 entity_id: self.state.entity_id,
16401 peer_entity_id: peer,
16402 seq: self.seq,
16403 })
16404 .await?;
16405 self.state.intents_sent += 1;
16406 self.state.social_chat.push_system(format!(
16407 "Trade request sent to {label} — waiting for accept"
16408 ));
16409 }
16410 "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
16411 _ => self.state.social_chat.focus_nearby(),
16412 }
16413 Ok(())
16414 }
16415
16416 pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
16417 let Some(pending) = self.state.social_chat.pending_trade.take() else {
16418 return Ok(());
16419 };
16420 self.seq += 1;
16421 self.session
16422 .submit_intent(Intent::TradeRespond {
16423 entity_id: self.state.entity_id,
16424 peer_entity_id: pending.from_entity,
16425 accept,
16426 seq: self.seq,
16427 })
16428 .await?;
16429 self.state.intents_sent += 1;
16430 if accept {
16431 self.state
16432 .social_chat
16433 .push_system(format!("Accepted trade with {}", pending.from_name));
16434 } else {
16435 self.state
16436 .social_chat
16437 .push_system(format!("Declined trade with {}", pending.from_name));
16438 }
16439 Ok(())
16440 }
16441
16442 pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
16443 let text = self.state.social_chat.buffer.trim().to_string();
16444 if text.is_empty() {
16445 return Ok(());
16446 }
16447 self.state.social_chat.buffer.clear();
16448 if crate::social::is_chat_slash_line(&text) {
16449 match crate::social::parse_chat_slash(&text) {
16450 Some(cmd) => return self.apply_chat_slash(cmd).await,
16451 None => {
16452 self.state.social_chat.push_system(format!(
16453 "Unknown command — {}",
16454 crate::social::chat_slash_help_text()
16455 ));
16456 return Ok(());
16457 }
16458 }
16459 }
16460 let thread = self.state.social_chat.thread;
16461 let channel = thread.channel();
16462 let to = thread.to_entity();
16463 if let Some(peer) = to {
16464 let label = self.state.social_chat.peer_label.clone();
16465 self.state
16466 .social_chat
16467 .remember_whisper_peer(peer, &label, channel);
16468 }
16469 self.say_to(channel, &text, to).await
16470 }
16471
16472 async fn apply_chat_slash(
16473 &mut self,
16474 cmd: crate::social::ChatSlashCommand,
16475 ) -> anyhow::Result<()> {
16476 use crate::social::{chat_slash_help_text, ChatSlashCommand};
16477 match cmd {
16478 ChatSlashCommand::Help => {
16479 self.state
16480 .social_chat
16481 .push_system(chat_slash_help_text().to_string());
16482 Ok(())
16483 }
16484 ChatSlashCommand::Nearby { message } => {
16485 self.state.social_chat.focus_nearby();
16486 self.state
16487 .social_chat
16488 .push_system("Nearby speech — everyone close can hear");
16489 if let Some(msg) = message {
16490 self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
16491 .await
16492 } else {
16493 Ok(())
16494 }
16495 }
16496 ChatSlashCommand::Reply { message } => {
16497 let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
16498 self.state
16499 .social_chat
16500 .push_system("No one to reply to — wait for a whisper, or /whisper Name");
16501 return Ok(());
16502 };
16503 let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
16504 self.state
16505 .social_chat
16506 .set_whisper_thread(peer.entity_id, &peer.label, stone);
16507 self.state.social_chat.push_system(format!(
16508 "Replying to {} — type and Enter · /nearby",
16509 peer.label
16510 ));
16511 if let Some(msg) = message {
16512 self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
16513 } else {
16514 Ok(())
16515 }
16516 }
16517 ChatSlashCommand::Whisper { name, message } => {
16518 let (peer_id, label, stone) = if let Some(name) = name {
16519 match self.resolve_whisper_target(&name) {
16520 Ok(t) => t,
16521 Err(err) => {
16522 self.state.social_chat.push_system(err);
16523 return Ok(());
16524 }
16525 }
16526 } else {
16527 let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
16528 self.state.social_chat.push_system(
16529 "Usage: /whisper Name [message] · or /reply after someone whispers you",
16530 );
16531 return Ok(());
16532 };
16533 (
16534 peer.entity_id,
16535 peer.label,
16536 peer.channel == flatland_protocol::ChatChannel::WhisperStone,
16537 )
16538 };
16539 self.state
16540 .social_chat
16541 .set_whisper_thread(peer_id, &label, stone);
16542 let channel = if stone {
16543 flatland_protocol::ChatChannel::WhisperStone
16544 } else {
16545 flatland_protocol::ChatChannel::Whisper
16546 };
16547 if let Some(msg) = message {
16548 self.state
16549 .social_chat
16550 .push_system(format!("Whisper → {label}"));
16551 self.say_to(channel, &msg, Some(peer_id)).await
16552 } else {
16553 self.state.social_chat.push_system(format!(
16554 "Whispering {label} — type and Enter · Esc / /nearby cancels"
16555 ));
16556 Ok(())
16557 }
16558 }
16559 }
16560 }
16561
16562 fn resolve_whisper_target(&self, name: &str) -> Result<(EntityId, String, bool), String> {
16564 let needle = name.trim().to_ascii_lowercase();
16565 if needle.is_empty() {
16566 return Err("Usage: /whisper Name [message]".into());
16567 }
16568 let mut candidates: Vec<(EntityId, String)> = self
16569 .state
16570 .entities
16571 .iter()
16572 .filter(|e| e.id != self.state.entity_id)
16573 .filter(|e| !e.label.trim().is_empty())
16574 .filter(|e| e.vitals.is_some())
16575 .filter(|e| !self.state.npcs.iter().any(|n| n.id == e.id.to_string()))
16576 .filter(|e| !self.state.hired_workers.iter().any(|w| w.entity_id == e.id))
16577 .map(|e| (e.id, e.label.clone()))
16578 .collect();
16579
16580 if let Some(last) = &self.state.social_chat.last_whisper_peer {
16582 if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
16583 candidates.push((last.entity_id, last.label.clone()));
16584 }
16585 }
16586
16587 let exact: Vec<_> = candidates
16588 .iter()
16589 .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
16590 .cloned()
16591 .collect();
16592 let pool = if exact.len() == 1 {
16593 exact
16594 } else if exact.len() > 1 {
16595 return Err(format!(
16596 "Several players named '{name}' nearby — move closer and try again"
16597 ));
16598 } else {
16599 let starts: Vec<_> = candidates
16600 .iter()
16601 .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
16602 .cloned()
16603 .collect();
16604 if starts.len() == 1 {
16605 starts
16606 } else if starts.len() > 1 {
16607 let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
16608 return Err(format!(
16609 "Ambiguous name '{name}' — matches: {}",
16610 names.join(", ")
16611 ));
16612 } else {
16613 let contains: Vec<_> = candidates
16614 .iter()
16615 .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
16616 .cloned()
16617 .collect();
16618 if contains.len() == 1 {
16619 contains
16620 } else if contains.is_empty() {
16621 return Err(format!(
16622 "No player matching '{name}' in range — get closer or check the spelling"
16623 ));
16624 } else {
16625 let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
16626 return Err(format!(
16627 "Ambiguous name '{name}' — matches: {}",
16628 names.join(", ")
16629 ));
16630 }
16631 }
16632 };
16633
16634 let (id, label) = pool.into_iter().next().unwrap();
16635 let stone = self
16636 .state
16637 .social_chat
16638 .last_whisper_peer
16639 .as_ref()
16640 .is_some_and(|p| {
16641 p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone
16642 });
16643 Ok((id, label, stone))
16644 }
16645
16646 pub async fn trade_present_selected(
16647 &mut self,
16648 item_instance_id: uuid::Uuid,
16649 ) -> anyhow::Result<()> {
16650 self.trade_present_quantity(item_instance_id, None).await
16651 }
16652
16653 pub async fn trade_present_quantity(
16654 &mut self,
16655 item_instance_id: uuid::Uuid,
16656 quantity: Option<u32>,
16657 ) -> anyhow::Result<()> {
16658 self.seq += 1;
16659 self.session
16660 .submit_intent(Intent::TradePresent {
16661 entity_id: self.state.entity_id,
16662 item_instance_id,
16663 quantity,
16664 seq: self.seq,
16665 })
16666 .await?;
16667 self.state.intents_sent += 1;
16668 self.state.trade_ui.qty_entry = None;
16669 self.state.trade_ui.picking_inventory = false;
16670 Ok(())
16671 }
16672
16673 pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
16675 if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
16676 let qty = self.state.trade_ui.present_quantity();
16677 return self
16678 .trade_present_quantity(entry.item_instance_id, qty)
16679 .await;
16680 }
16681 if !self.state.trade_ui.picking_inventory {
16682 return Ok(());
16683 }
16684 let stacks = self.state.trade_presentable_stacks();
16685 let Some(stack) = stacks.get(self.state.trade_ui.inventory_index).copied() else {
16686 return Ok(());
16687 };
16688 let Some(id) = stack.item_instance_id else {
16689 return Ok(());
16690 };
16691 let label = stack
16692 .display_name
16693 .clone()
16694 .unwrap_or_else(|| stack.template_id.clone());
16695 if stack.quantity <= 1 {
16696 self.trade_present_quantity(id, Some(1)).await
16697 } else {
16698 self.state
16699 .trade_ui
16700 .begin_qty_entry(id, label, stack.quantity);
16701 Ok(())
16702 }
16703 }
16704
16705 pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
16706 self.seq += 1;
16707 self.session
16708 .submit_intent(Intent::TradeSetReady {
16709 entity_id: self.state.entity_id,
16710 ready,
16711 seq: self.seq,
16712 })
16713 .await?;
16714 self.state.intents_sent += 1;
16715 Ok(())
16716 }
16717
16718 pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
16719 self.seq += 1;
16720 self.session
16721 .submit_intent(Intent::TradeCancel {
16722 entity_id: self.state.entity_id,
16723 seq: self.seq,
16724 })
16725 .await?;
16726 self.state.intents_sent += 1;
16727 self.state.trade_ui.close();
16728 Ok(())
16729 }
16730
16731 pub async fn destroy_whisper_stone(
16732 &mut self,
16733 item_instance_id: uuid::Uuid,
16734 ) -> anyhow::Result<()> {
16735 self.seq += 1;
16736 self.session
16737 .submit_intent(Intent::DestroyWhisperStone {
16738 entity_id: self.state.entity_id,
16739 item_instance_id,
16740 seq: self.seq,
16741 })
16742 .await?;
16743 self.state.intents_sent += 1;
16744 Ok(())
16745 }
16746
16747 pub async fn stop(&mut self) -> anyhow::Result<()> {
16748 self.seq += 1;
16749 self.session
16750 .submit_intent(Intent::Stop {
16751 entity_id: self.state.entity_id,
16752 seq: self.seq,
16753 })
16754 .await?;
16755 self.state.intents_sent += 1;
16756 Ok(())
16757 }
16758
16759 pub fn disconnect(&self) {
16760 self.session.disconnect();
16761 }
16762}
16763
16764fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
16765 let dx = ax - bx;
16766 let dy = ay - by;
16767 (dx * dx + dy * dy).sqrt()
16768}
16769
16770#[cfg(test)]
16771mod tests {
16772 use std::collections::BTreeMap;
16773
16774 use super::*;
16775 use flatland_protocol::{
16776 BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
16777 };
16778
16779 fn sample_state() -> GameState {
16780 let mut state = GameState {
16781 session_id: 1,
16782 entity_id: 1,
16783 character_id: None,
16784 tick: 0,
16785 chunk_rev: 0,
16786 content_rev: 0,
16787 publish_rev: 0,
16788 entities: vec![EntityState {
16789 id: 1,
16790 label: "You".into(),
16791 transform: Transform {
16792 position: WorldCoord::surface(128.0, 128.0),
16793 yaw: 0.0,
16794 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
16795 },
16796 vitals: None,
16797 attributes: None,
16798 skills: None,
16799 inside_building: None,
16800 tile_id: None,
16801 paperdoll_ref: None,
16802 draw_scale: 1.0,
16803 presentation_state: None,
16804 sprite_mode: None,
16805 progression_xp: None,
16806 combat_cues: vec![],
16807 statuses: vec![],
16808 }],
16809 player: None,
16810 resource_nodes: vec![ResourceNodeView {
16811 id: "oak-1".into(),
16812 label: "Oak".into(),
16813 x: 130.0,
16814 y: 128.0,
16815 z: 0.0,
16816 item_template: "oak_log".into(),
16817 state: ResourceNodeState::Available,
16818 blocking: true,
16819 blocking_radius_m: 0.8,
16820 harvest_off: false,
16821 tile_id: None,
16822 yaw: 0.0,
16823 pitch: 0.0,
16824 roll: 0.0,
16825 draw_scale: 1.0,
16826 sprite_mode: None,
16827 growth_progress: None,
16828 presentation_state: None,
16829 channel_start_tick: None,
16830 channel_end_tick: None,
16831 harvest_drop_templates: vec![],
16832 }],
16833 harvest_route_nodes: vec![],
16834 ground_drops: vec![],
16835 placed_containers: vec![],
16836 buildings: vec![BuildingView {
16837 id: "broker-hut".into(),
16838 label: "Broker".into(),
16839 x: 148.0,
16840 y: 118.0,
16841 width_m: 8.0,
16842 depth_m: 6.0,
16843 interior_blueprint: Some("broker_hut".into()),
16844 tags: vec![],
16845 market_boundary_zone_ids: vec![],
16846 market_max_volume: None,
16847 wall_set: None,
16848 roof_set: None,
16849 }],
16850 doors: vec![flatland_protocol::DoorView {
16851 id: "door-1".into(),
16852 building_id: "broker-hut".into(),
16853 x: 148.0,
16854 y: 118.0,
16855 open: false,
16856 portal: Some("front".into()),
16857 locked: false,
16858 accessible: true,
16859 lock_id: None,
16860 }],
16861 interior_map: None,
16862 npcs: vec![],
16863 blueprints: vec![],
16864 building_materials: vec![],
16865 world_x0: 0.0,
16866 world_y0: 0.0,
16867 world_width_m: 256.0,
16868 world_height_m: 256.0,
16869 terrain_zones: Vec::new(),
16870 z_platforms: Vec::new(),
16871 z_transitions: Vec::new(),
16872 z_bands_outdoor_backup: None,
16873 world_clock: flatland_protocol::WorldClock::default(),
16874 inventory: std::collections::HashMap::new(),
16875 inventory_hints: std::collections::HashMap::new(),
16876 item_catalog: std::collections::HashMap::new(),
16877 logs: VecDeque::new(),
16878 intents_sent: 0,
16879 ticks_received: 0,
16880 connected: true,
16881 disconnect_reason: None,
16882 show_stats: false,
16883 hud_log_hidden: false,
16884 show_equip_menu: false,
16885 equip_menu_index: 0,
16886 show_craft_menu: false,
16887 show_plot_build_menu: false,
16888 plot_build_focus_wall: true,
16889 plot_build_wall_index: 0,
16890 plot_build_roof_index: 0,
16891 craft_menu_index: 0,
16892 craft_batch_quantity: 1,
16893 craft_tab: CraftTab::Ready,
16894 craft_filter: String::new(),
16895 craft_filter_focused: false,
16896 craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
16897 show_shop_menu: false,
16898 shop_catalog: None,
16899 bank_panel: None,
16900 bank_menu_index: 0,
16901 bank_ui_mode: BankUiMode::Menu,
16902 storage_panel: None,
16903 market_panel: None,
16904 market_menu_index: 0,
16905 market_filter: String::new(),
16906 market_filter_focused: false,
16907 market_category_filter: None,
16908 market_buy_confirm: None,
16909 market_ui_mode: MarketUiMode::Browse,
16910 storage_menu_index: 0,
16911 storage_ui_mode: StorageUiMode::Menu,
16912 shop_tab: ShopTab::default(),
16913 shop_menu_index: 0,
16914 shop_quantity: 1,
16915 shop_trade_log: VecDeque::new(),
16916 show_npc_verb_menu: false,
16917 npc_verb_target: None,
16918 npc_verb_index: 0,
16919 npc_verb_notice: None,
16920 player_verbs: crate::social::PlayerVerbState::default(),
16921 social_chat: crate::social::SocialChatState::default(),
16922 trade_ui: crate::social::TradeUiState::default(),
16923 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
16924 show_npc_chat: false,
16925 npc_chat: None,
16926 show_inventory_menu: false,
16927 inventory_menu_index: 0,
16928 inventory_tab: InventoryTab::OnPerson,
16929 inventory_filter: String::new(),
16930 inventory_filter_focused: false,
16931 show_move_picker: false,
16932 show_rename_prompt: false,
16933 rename_plot_id: None,
16934 highlighted_plot_id: None,
16935 show_worker_rename: false,
16936 rename_buffer: String::new(),
16937 move_picker_index: 0,
16938 move_picker: None,
16939 show_grant_picker: false,
16940 grant_picker_index: 0,
16941 grant_picker: None,
16942 show_destroy_picker: false,
16943 destroy_confirm_pending: false,
16944 destroy_picker: None,
16945 combat_target: None,
16946 combat_target_label: None,
16947 ground_target: None,
16948 combat_fx: Vec::new(),
16949 ground_hazards: Vec::new(),
16950 property_zones: Vec::new(),
16951 tax_zones: Vec::new(),
16952 growth_zones: Vec::new(),
16953 biome_zones: Vec::new(),
16954 terrain_kind_nav: Vec::new(),
16955 property_plots: Vec::new(),
16956 property_plot_settings: None,
16957 claim_mode: None,
16958 relocate_mode: None,
16959 sell_plot_confirm: None,
16960 sell_plot_armed_at: None,
16961 show_plant_menu: false,
16962 plant_menu_index: 0,
16963 show_farm_access: false,
16964 farm_access_name_draft: String::new(),
16965 farm_access_discount_bps: 0,
16966 farm_access_index: 0,
16967 plant_quantity: 1,
16968 in_combat: false,
16969 auto_attack: true,
16970 combat_has_los: false,
16971 attack_cd_ticks: 0,
16972 gcd_ticks: 0,
16973 weapon_ability_id: "unarmed".into(),
16974 mainhand_template_id: None,
16975 mainhand_label: None,
16976 mainhand_instance_id: None,
16977 offhand_template_id: None,
16978 offhand_label: None,
16979 offhand_instance_id: None,
16980 mainhand_hand_slots: 1,
16981 defense: None,
16982 worn: BTreeMap::new(),
16983 carry_mass: 0.0,
16984 carry_mass_max: 0.0,
16985 encumbrance: flatland_protocol::EncumbranceState::Light,
16986 move_speed_mps: 0.0,
16987 move_speed_mult: 0.0,
16988 inventory_stacks: Vec::new(),
16989 keychain_stacks: Vec::new(),
16990 whisper_pouch_stacks: Vec::new(),
16991 combat_target_detail: None,
16992 statuses: Vec::new(),
16993 cast_progress: None,
16994 timed_channel: None,
16995 plot_build_offer: None,
16996 ability_cooldowns: Vec::new(),
16997 blocking_active: false,
16998 max_target_slots: 1,
16999 combat_slots: Vec::new(),
17000 rotation_presets: Vec::new(),
17001 known_abilities: Vec::new(),
17002 ability_meta: std::collections::HashMap::new(),
17003 ability_mastery: std::collections::HashMap::new(),
17004 hotbar: vec![None; 9],
17005 max_abilities_per_rotation: 0,
17006 show_loadout_menu: false,
17007 show_keychain_menu: false,
17008 keychain_menu_index: 0,
17009 show_rotation_editor: false,
17010 loadout_menu_index: 0,
17011 loadout_hotbar_slot: 1,
17012 loadout_ability_index: 0,
17013 loadout_focus_presets: false,
17014 rotation_editor: RotationEditorState::default(),
17015 harvest_in_progress: false,
17016 harvest_started_at: None,
17017 pending_craft_ack: None,
17018 craft_channel_blueprint_id: None,
17019 pending_worker_job_ack: None,
17020 attending_worker_instance_id: None,
17021 quest_log: Vec::new(),
17022 interactables: Vec::new(),
17023 ledger: None,
17024 career: None,
17025 character_sheet_tab: CharacterSheetTab::Character,
17026 ledger_period: LedgerPeriod::Day,
17027 show_quest_offer: false,
17028 pending_quest_offers: Vec::new(),
17029 quest_offer_index: 0,
17030 show_quest_menu: false,
17031 quest_menu_index: 0,
17032 quest_withdraw_confirm: false,
17033 hired_workers: Vec::new(),
17034 show_workers_menu: false,
17035 workers_menu_index: 0,
17036 worker_dismiss_confirmation: None,
17037 workers_menu_compact: false,
17038 worker_step_display: BTreeMap::new(),
17039 worker_error_display: BTreeMap::new(),
17040 worker_health_ring_until: BTreeMap::new(),
17041 pending_worker_hire_since: None,
17042 show_worker_give_picker: false,
17043 worker_give_picker_index: 0,
17044 worker_give_picker: None,
17045 show_worker_give_target_picker: false,
17046 worker_give_target_picker_index: 0,
17047 worker_give_target_picker: None,
17048 show_worker_take_picker: false,
17049 worker_take_picker_index: 0,
17050 worker_take_picker: None,
17051 show_worker_teach_picker: false,
17052 worker_teach_picker_index: 0,
17053 worker_teach_picker: None,
17054 worker_route_editor: None,
17055 progression_curve: None,
17056 };
17057 state.player = state.entities.first().cloned();
17058 state
17059 }
17060
17061 #[test]
17062 fn template_display_name_uses_item_catalog_for_uuid_ids() {
17063 let mut state = sample_state();
17064 let id = "7ac81ed5-e3d3-4b7f-a7fb-11d6c34a0995";
17065 assert_eq!(state.template_display_name(id), "Unknown item");
17066 state.item_catalog.insert(
17067 id.into(),
17068 ItemCatalogEntryView {
17069 template_id: id.into(),
17070 display_name: "Emerald".into(),
17071 category: "resource".into(),
17072 seed_for: None,
17073 },
17074 );
17075 assert_eq!(state.template_display_name(id), "Emerald");
17076 }
17077
17078 #[test]
17079 fn whisper_cancels_when_peer_walks_out_of_range() {
17080 let mut state = sample_state();
17081 state.player = state.entities.first().cloned();
17082 let mut peer = state.entities[0].clone();
17083 peer.id = 2;
17084 peer.label = "Ada".into();
17085 peer.transform.position = WorldCoord::surface(129.0, 128.0); state.entities.push(peer.clone());
17087 state.social_chat.focus_whisper(2, "Ada");
17088 state.refresh_whisper_range();
17089 assert!(matches!(
17090 state.social_chat.thread,
17091 crate::social::ChatThreadKind::Whisper { peer: 2 }
17092 ));
17093
17094 peer.transform.position = WorldCoord::surface(132.0, 128.0); state.entities[1] = peer;
17096 state.refresh_whisper_range();
17097 assert_eq!(
17098 state.social_chat.thread,
17099 crate::social::ChatThreadKind::Nearby
17100 );
17101 assert!(!state.social_chat.input_focused);
17102 }
17103
17104 #[test]
17105 fn probe_use_world_hired_worker_manage() {
17106 let mut state = sample_state();
17107 state
17108 .hired_workers
17109 .push(flatland_protocol::HiredWorkerView {
17110 instance_id: "worker-1".into(),
17111 entity_id: 42,
17112 def_id: "worker_laborer".into(),
17113 label: "Sam".into(),
17114 x: 129.0,
17115 y: 128.0,
17116 z: 0.0,
17117 mode: flatland_protocol::WorkerModeView::JobLoop,
17118 state: flatland_protocol::WorkerStateView::Working,
17119 step_label: "cultivate".into(),
17120 vitals: flatland_protocol::WorkerVitalsSummary {
17121 health_pct: 100.0,
17122 stamina_pct: 100.0,
17123 mana_pct: 100.0,
17124 hunger_pct: 100.0,
17125 thirst_pct: 100.0,
17126 },
17127 carry_pct: 0.0,
17128 last_error: None,
17129 wage_copper_per_interval: 1,
17130 effective_wage_copper: 1,
17131 wage_meters_walked: 0.0,
17132 lodging_container_id: None,
17133 route: None,
17134 route_stop_index: None,
17135 known_blueprint_ids: Vec::new(),
17136 level: 1,
17137 worker_xp: 0.0,
17138 inventory: Vec::new(),
17139 equipment: flatland_protocol::WorkerEquipmentView::default(),
17140 issue_hint: None,
17141 });
17142 let probe = state.probe_use_world();
17143 let primary = probe.primary.expect("primary");
17144 assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
17145 assert_eq!(primary.id, "worker-1");
17146 assert!(primary.hint_line().contains("Manage"));
17147 assert!(primary.hint_line().contains("Sam"));
17148 assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
17149 }
17150
17151 fn sample_hired_worker(x: f32, y: f32) -> flatland_protocol::HiredWorkerView {
17152 flatland_protocol::HiredWorkerView {
17153 instance_id: "worker-1".into(),
17154 entity_id: 42,
17155 def_id: "worker_laborer".into(),
17156 label: "Sam".into(),
17157 x,
17158 y,
17159 z: 0.0,
17160 mode: flatland_protocol::WorkerModeView::JobLoop,
17161 state: flatland_protocol::WorkerStateView::Working,
17162 step_label: "follow".into(),
17163 vitals: flatland_protocol::WorkerVitalsSummary {
17164 health_pct: 100.0,
17165 stamina_pct: 100.0,
17166 mana_pct: 100.0,
17167 hunger_pct: 100.0,
17168 thirst_pct: 100.0,
17169 },
17170 carry_pct: 0.0,
17171 last_error: None,
17172 wage_copper_per_interval: 1,
17173 effective_wage_copper: 1,
17174 wage_meters_walked: 0.0,
17175 lodging_container_id: None,
17176 route: None,
17177 route_stop_index: None,
17178 known_blueprint_ids: Vec::new(),
17179 level: 1,
17180 worker_xp: 0.0,
17181 inventory: Vec::new(),
17182 equipment: flatland_protocol::WorkerEquipmentView::default(),
17183 issue_hint: None,
17184 }
17185 }
17186
17187 #[test]
17188 fn probe_harvest_beats_closer_hired_worker() {
17189 let mut state = sample_state();
17190 state.resource_nodes[0].x = 129.0;
17191 state.resource_nodes[0].y = 128.0;
17192 state.hired_workers.push(sample_hired_worker(128.2, 128.0));
17193 let probe = state.probe_use_world();
17194 let primary = probe.primary.expect("primary");
17195 assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
17196 assert_eq!(primary.id, "oak-1");
17197 assert!(state.harvestable_node_in_range());
17198 assert_eq!(
17199 state.nearest_interact_target().as_deref(),
17200 Some("worker-1"),
17201 "harvest is not Interact — worker remains the interact target"
17202 );
17203 }
17204
17205 #[test]
17206 fn probe_door_beats_closer_hired_worker() {
17207 let mut state = sample_state();
17208 state.doors[0].x = 129.2;
17209 state.doors[0].y = 128.0;
17210 state.hired_workers.push(sample_hired_worker(128.3, 128.0));
17211 let probe = state.probe_use_world();
17212 let primary = probe.primary.expect("primary");
17213 assert!(
17214 matches!(
17215 primary.kind,
17216 crate::UseWorldKind::EnterDoor
17217 | crate::UseWorldKind::OpenDoor
17218 | crate::UseWorldKind::CloseDoor
17219 | crate::UseWorldKind::ExitDoor
17220 ),
17221 "door should win over closer worker, got {:?}",
17222 primary.kind
17223 );
17224 assert_eq!(primary.id, "door-1");
17225 assert_eq!(state.nearest_interact_target().as_deref(), Some("door-1"));
17226 }
17227
17228 #[test]
17229 fn probe_indoor_exit_door_beats_lodging_chest_pickup() {
17230 let mut state = sample_state();
17233 state.entities[0].inside_building = Some("player_house".into());
17234 state.entities[0].transform.position = WorldCoord::surface(5.0, 2.0);
17235 state.player = state.entities.first().cloned();
17236 state.buildings = vec![BuildingView {
17237 id: "player_house".into(),
17238 label: "MadSin's house".into(),
17239 x: 100.0,
17240 y: 100.0,
17241 width_m: 10.0,
17242 depth_m: 8.0,
17243 interior_blueprint: Some("player_house".into()),
17244 tags: vec!["player_built".into()],
17245 market_boundary_zone_ids: vec![],
17246 market_max_volume: None,
17247 wall_set: None,
17248 roof_set: None,
17249 }];
17250 state.doors = vec![flatland_protocol::DoorView {
17251 id: "house_exit".into(),
17252 building_id: "player_house".into(),
17253 x: 5.0,
17254 y: 1.0,
17255 open: true,
17256 portal: Some("front".into()),
17257 locked: false,
17258 accessible: true,
17259 lock_id: None,
17260 }];
17261 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
17262 id: "lodging_bed".into(),
17263 template_id: "camp_bed".into(),
17264 display_name: "Camp bed".into(),
17265 x: 5.5,
17266 y: 2.4,
17267 z: 0.0,
17268 locked: false,
17269 accessible: true,
17270 owner_character_id: None,
17271 contents: vec![],
17272 lock_id: None,
17273 capacity_volume: None,
17274 item_instance_id: Some(uuid::Uuid::from_u128(99)),
17275 tile_id: None,
17276 worker_lodging_capacity: Some(1),
17277 blocking: false,
17278 blocking_radius_m: 0.0,
17279 building_id: Some("player_house".into()),
17280 }];
17281 let mut worker = sample_hired_worker(40.0, 40.0);
17283 worker.lodging_container_id = Some("lodging_bed".into());
17284 state.hired_workers.push(worker);
17285
17286 let probe = state.probe_use_world();
17287 let primary = probe.primary.expect("primary");
17288 assert!(
17289 matches!(
17290 primary.kind,
17291 crate::UseWorldKind::ExitDoor
17292 | crate::UseWorldKind::OpenDoor
17293 | crate::UseWorldKind::CloseDoor
17294 | crate::UseWorldKind::EnterDoor
17295 ),
17296 "indoor exit must beat lodging ChestPickup, got {:?}",
17297 primary.kind
17298 );
17299 assert_eq!(primary.id, "house_exit");
17300 assert_eq!(primary.kind.cascade_stage(), 0);
17301 assert!(
17302 probe
17303 .candidates
17304 .iter()
17305 .any(|c| c.kind == crate::UseWorldKind::ChestPickup && c.in_range),
17306 "lodging bed should still be an in-range chest candidate"
17307 );
17308 assert_eq!(
17309 state.nearest_interact_target().as_deref(),
17310 Some("house_exit"),
17311 "use_nearest interact path should target the door"
17312 );
17313 assert!(state.lodging_is_occupied("lodging_bed"));
17314 }
17315
17316 #[test]
17317 fn probe_worker_when_no_resource_or_door_in_range() {
17318 let mut state = sample_state();
17319 state.hired_workers.push(sample_hired_worker(129.0, 128.0));
17321 let probe = state.probe_use_world();
17322 let primary = probe.primary.expect("primary");
17323 assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
17324 assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
17325 assert!(!state.harvestable_node_in_range());
17326 }
17327
17328 #[test]
17329 fn market_clerk_verb_options_include_market() {
17330 let mut state = sample_state();
17331 state.npcs.push(flatland_protocol::NpcView {
17332 id: "mira_market".into(),
17333 label: "Mira".into(),
17334 role: "market_clerk".into(),
17335 x: 129.0,
17336 y: 128.0,
17337 building_id: Some("town_market".into()),
17338 entity_id: None,
17339 life_state: None,
17340 hp_pct: None,
17341 can_trade: false,
17342 buy_templates: vec![],
17343 tile_id: None,
17344 behavior_state: None,
17345 presentation_state: None,
17346 sprite_mode: None,
17347 paperdoll_ref: None,
17348 draw_scale: 1.0,
17349 yaw: None,
17350 perception_fov_deg: None,
17351 perception_sight_m: None,
17352 perception_hear_m: None,
17353 quest_verbs: Vec::new(),
17354 });
17355 state.npc_verb_target = Some("mira_market".into());
17356 assert_eq!(
17357 state
17358 .npc_verb_options()
17359 .iter()
17360 .map(|v| v.label.as_str())
17361 .collect::<Vec<_>>(),
17362 vec!["Market", "Talk"]
17363 );
17364 }
17365
17366 #[test]
17367 fn butcher_verb_options_include_turn_in_for_give_item() {
17368 let mut state = sample_state();
17369 state.npcs.push(flatland_protocol::NpcView {
17370 id: "town_butcher_1".into(),
17371 label: "Brutus".into(),
17372 role: "butcher".into(),
17373 x: 129.0,
17374 y: 128.0,
17375 building_id: None,
17376 entity_id: None,
17377 life_state: None,
17378 hp_pct: None,
17379 can_trade: true,
17380 buy_templates: vec!["raw_venison".into()],
17381 tile_id: None,
17382 behavior_state: None,
17383 presentation_state: None,
17384 sprite_mode: None,
17385 paperdoll_ref: None,
17386 draw_scale: 1.0,
17387 yaw: None,
17388 perception_fov_deg: None,
17389 perception_sight_m: None,
17390 perception_hear_m: None,
17391 quest_verbs: Vec::new(),
17392 });
17393 state.quest_log.push(flatland_protocol::QuestLogEntry {
17394 quest_id: "deer_threat".into(),
17395 title: "Deer threat".into(),
17396 description: String::new(),
17397 status: flatland_protocol::QuestStatusView::Active,
17398 current_step_id: Some("deliver".into()),
17399 current_step_title: "Deliver venison".into(),
17400 current_step_index: 0,
17401 objectives: vec![flatland_protocol::QuestObjectiveProgress {
17402 label: "Give 3 Raw venison to Brutus".into(),
17403 current: 0,
17404 required: 3,
17405 done: false,
17406 kind: "give_item".into(),
17407 npc_ref: Some("town_butcher_1".into()),
17408 item_template: Some("raw_venison".into()),
17409 blueprint_id: None,
17410 building_id: None,
17411 }],
17412 current_step_reward: flatland_protocol::QuestRewardView::default(),
17413 completion_reward: flatland_protocol::QuestRewardView::default(),
17414 steps: Vec::new(),
17415 is_tracked: true,
17416 can_withdraw: true,
17417 });
17418 state.npc_verb_target = Some("town_butcher_1".into());
17419 assert_eq!(
17420 state
17421 .npc_verb_options()
17422 .iter()
17423 .map(|v| v.label.as_str())
17424 .collect::<Vec<_>>(),
17425 vec!["Turn in: Deer threat", "Talk", "Trade"]
17426 );
17427 }
17428
17429 #[test]
17430 fn ada_verb_options_include_quest_offer() {
17431 let mut state = sample_state();
17432 state.npcs.push(flatland_protocol::NpcView {
17433 id: "ada_broker".into(),
17434 label: "Ada".into(),
17435 role: "broker".into(),
17436 x: 129.0,
17437 y: 128.0,
17438 building_id: None,
17439 entity_id: None,
17440 life_state: None,
17441 hp_pct: None,
17442 can_trade: true,
17443 buy_templates: vec![],
17444 tile_id: None,
17445 behavior_state: None,
17446 presentation_state: None,
17447 sprite_mode: None,
17448 paperdoll_ref: Some("ada_broker".into()),
17449 draw_scale: 1.0,
17450 yaw: None,
17451 perception_fov_deg: None,
17452 perception_sight_m: None,
17453 perception_hear_m: None,
17454 quest_verbs: vec![flatland_protocol::NpcQuestVerb {
17455 quest_id: "ada_goblin_hunt".into(),
17456 label: "Ask about goblins".into(),
17457 kind: flatland_protocol::NpcQuestVerb::KIND_OFFER.into(),
17458 }],
17459 });
17460 state.npc_verb_target = Some("ada_broker".into());
17461 assert_eq!(
17462 state
17463 .npc_verb_options()
17464 .iter()
17465 .map(|v| v.label.as_str())
17466 .collect::<Vec<_>>(),
17467 vec!["Ask about goblins", "Talk", "Trade"]
17468 );
17469 }
17470
17471 #[test]
17472 fn market_list_excludes_currency_stacks() {
17473 let mut state = sample_state();
17474 state.inventory_stacks = vec![
17475 flatland_protocol::ItemStack {
17476 template_id: "copper_coin".into(),
17477 quantity: 50,
17478 item_instance_id: Some(uuid::Uuid::from_u128(10)),
17479 display_name: Some("Copper Coin".into()),
17480 ..Default::default()
17481 },
17482 flatland_protocol::ItemStack {
17483 template_id: "oak_log".into(),
17484 quantity: 2,
17485 item_instance_id: Some(uuid::Uuid::from_u128(11)),
17486 display_name: Some("Oak Log".into()),
17487 ..Default::default()
17488 },
17489 flatland_protocol::ItemStack {
17490 template_id: "whisper_stone".into(),
17491 quantity: 1,
17492 item_instance_id: Some(uuid::Uuid::from_u128(12)),
17493 display_name: Some("Whisper Stone".into()),
17494 category: Some("quest".into()),
17495 listable: Some(false),
17496 ..Default::default()
17497 },
17498 ];
17499 let opts = state.market_list_item_options(&MarketListSourceKind::Person);
17500 assert_eq!(opts.len(), 1);
17501 assert!(opts[0].label.contains("Oak"));
17502 }
17503
17504 #[test]
17505 fn market_browse_filters_by_category_and_search() {
17506 let mut state = sample_state();
17507 state.market_panel = Some(flatland_protocol::MarketPanel {
17508 npc_id: "mira_market".into(),
17509 npc_label: "Mira".into(),
17510 building_id: "town_market".into(),
17511 building_label: "Town Market".into(),
17512 used_volume: 0.0,
17513 max_volume: 100.0,
17514 listings: vec![
17515 flatland_protocol::MarketListingView {
17516 listing_id: uuid::Uuid::from_u128(1),
17517 seller_character_id: uuid::Uuid::from_u128(2),
17518 seller_label: "Ada".into(),
17519 hall_building_id: "town_market".into(),
17520 hall_label: "Town Market".into(),
17521 template_id: "oak_log".into(),
17522 display_name: "Oak Log".into(),
17523 category: "resource".into(),
17524 quantity: 3,
17525 unit_price_copper: 10,
17526 line_total_copper: 30,
17527 npc_price: false,
17528 npc_dump_unit_copper: None,
17529 mine: false,
17530 },
17531 flatland_protocol::MarketListingView {
17532 listing_id: uuid::Uuid::from_u128(3),
17533 seller_character_id: uuid::Uuid::from_u128(2),
17534 seller_label: "Ada".into(),
17535 hall_building_id: "town_market".into(),
17536 hall_label: "Town Market".into(),
17537 template_id: "short_sword".into(),
17538 display_name: "Short Sword".into(),
17539 category: "weapon".into(),
17540 quantity: 1,
17541 unit_price_copper: 100,
17542 line_total_copper: 100,
17543 npc_price: false,
17544 npc_dump_unit_copper: None,
17545 mine: false,
17546 },
17547 ],
17548 tax_bps: 0,
17549 tax_flat_copper: 0,
17550 list_vaults: vec![],
17551 });
17552 assert_eq!(state.market_filtered_listing_indices().len(), 2);
17553 state.market_category_filter = Some("Weapons");
17554 let weapons = state.market_filtered_listing_indices();
17555 assert_eq!(weapons.len(), 1);
17556 assert_eq!(
17557 state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
17558 "Short Sword"
17559 );
17560 state.market_category_filter = None;
17561 state.market_filter = "oak".into();
17562 let oak = state.market_filtered_listing_indices();
17563 assert_eq!(oak.len(), 1);
17564 assert_eq!(
17565 state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
17566 "Oak Log"
17567 );
17568 }
17569
17570 #[test]
17571 fn market_list_source_includes_person_and_vaults() {
17572 let mut state = sample_state();
17573 let item_id = uuid::Uuid::from_u128(1);
17574 state.inventory_stacks = vec![flatland_protocol::ItemStack {
17575 template_id: "oak_log".into(),
17576 quantity: 2,
17577 item_instance_id: Some(item_id),
17578 display_name: Some("Oak Log".into()),
17579 ..Default::default()
17580 }];
17581 state.market_panel = Some(flatland_protocol::MarketPanel {
17582 npc_id: "mira_market".into(),
17583 npc_label: "Mira".into(),
17584 building_id: "town_market".into(),
17585 building_label: "Town Market".into(),
17586 used_volume: 0.0,
17587 max_volume: 100.0,
17588 listings: vec![],
17589 tax_bps: 0,
17590 tax_flat_copper: 0,
17591 list_vaults: vec![flatland_protocol::MarketListVault {
17592 building_id: "town_storage".into(),
17593 building_label: "Town Storage".into(),
17594 contents: vec![flatland_protocol::ItemStack {
17595 template_id: "lumber".into(),
17596 quantity: 1,
17597 item_instance_id: Some(uuid::Uuid::from_u128(2)),
17598 display_name: Some("Lumber".into()),
17599 ..Default::default()
17600 }],
17601 }],
17602 });
17603 let sources = state.market_list_source_options();
17604 assert_eq!(sources.len(), 2);
17605 assert!(matches!(sources[0].0, MarketListSourceKind::Person));
17606 assert!(matches!(
17607 sources[1].0,
17608 MarketListSourceKind::TownStorage { .. }
17609 ));
17610 assert!(sources[1].1.contains("Town Storage"));
17611 }
17612
17613 #[test]
17614 fn npc_market_dump_estimate_from_town_storage_vault() {
17615 let mut state = sample_state();
17616 state.market_panel = Some(flatland_protocol::MarketPanel {
17617 npc_id: "mira_market".into(),
17618 npc_label: "Mira".into(),
17619 building_id: "town_market".into(),
17620 building_label: "Town Market".into(),
17621 used_volume: 0.0,
17622 max_volume: 100.0,
17623 listings: vec![],
17624 tax_bps: 0,
17625 tax_flat_copper: 0,
17626 list_vaults: vec![flatland_protocol::MarketListVault {
17627 building_id: "town_storage".into(),
17628 building_label: "Town Storage".into(),
17629 contents: vec![flatland_protocol::ItemStack {
17630 template_id: "lumber".into(),
17631 quantity: 3,
17632 item_instance_id: Some(uuid::Uuid::from_u128(2)),
17633 display_name: Some("Lumber".into()),
17634 base_value_copper: Some(20),
17635 ..Default::default()
17636 }],
17637 }],
17638 });
17639 assert_eq!(
17640 state.npc_market_dump_unit_estimate("lumber"),
17641 Some(9),
17642 "vault stack base_value should enable NPC price estimate"
17643 );
17644 }
17645
17646 #[test]
17647 fn probe_use_world_npc_beats_nearby_loot() {
17648 let mut state = sample_state();
17649 state.npcs.push(flatland_protocol::NpcView {
17650 id: "ada".into(),
17651 label: "Ada".into(),
17652 role: "broker".into(),
17653 x: 129.0,
17654 y: 128.0,
17655 building_id: None,
17656 entity_id: None,
17657 life_state: None,
17658 hp_pct: None,
17659 can_trade: true,
17660 buy_templates: vec!["lumber".into()],
17661 tile_id: None,
17662 behavior_state: None,
17663 presentation_state: None,
17664 sprite_mode: None,
17665 paperdoll_ref: None,
17666 draw_scale: 1.0,
17667 yaw: None,
17668 perception_fov_deg: None,
17669 perception_sight_m: None,
17670 perception_hear_m: None,
17671 quest_verbs: Vec::new(),
17672 });
17673 state.ground_drops.push(flatland_protocol::GroundDropView {
17674 id: "d1".into(),
17675 template_id: "lumber".into(),
17676 quantity: 1,
17677 x: 128.5,
17678 y: 128.0,
17679 z: 0.0,
17680 tile_id: None,
17681 display_name: None,
17682 yaw: 0.0,
17683 pitch: 0.0,
17684 roll: 0.0,
17685 draw_scale: 1.0,
17686 item_instance_id: None,
17687 props: Default::default(),
17688 status_bindings: Vec::new(),
17689 });
17690 let probe = state.probe_use_world();
17691 let primary = probe.primary.expect("primary");
17692 assert_eq!(primary.kind, crate::UseWorldKind::Npc);
17693 assert_eq!(primary.id, "ada");
17694 }
17695
17696 #[test]
17697 fn probe_use_world_harvest_when_in_range() {
17698 let state = sample_state(); let probe = state.probe_use_world();
17700 assert!(
17701 probe.primary.is_none(),
17702 "oak is 2m away, out of harvest range"
17703 );
17704 assert!(probe
17705 .candidates
17706 .iter()
17707 .any(|c| c.kind == crate::UseWorldKind::Harvest));
17708
17709 let mut state = sample_state();
17710 state.resource_nodes[0].x = 129.0;
17711 let probe = state.probe_use_world();
17712 let primary = probe.primary.expect("primary");
17713 assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
17714 }
17715
17716 #[test]
17717 fn probe_use_world_door_uses_building_label() {
17718 let mut state = sample_state();
17719 state.doors[0].x = 129.0;
17720 state.doors[0].y = 128.0;
17721 let probe = state.probe_use_world();
17722 let primary = probe.primary.expect("primary");
17723 assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
17724 assert_eq!(primary.label, "Broker");
17725 assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
17726 }
17727
17728 #[test]
17729 fn empty_entity_tick_preserves_welcome_snapshot() {
17730 let mut state = sample_state();
17731 state.inventory.insert("carrot".into(), 3);
17732 let delta = TickDelta {
17733 tick: 1,
17734 entities: vec![],
17735 resource_nodes: vec![],
17736 ground_drops: vec![],
17737 placed_containers: vec![],
17738 buildings: vec![],
17739 doors: vec![],
17740 interior_map: None,
17741 npcs: vec![],
17742 inventory: vec![],
17743 blueprints: vec![],
17744 building_materials: vec![],
17745 world_clock: flatland_protocol::WorldClock::default(),
17746 combat: None,
17747 quest_log: vec![],
17748 hired_workers: Vec::new(),
17749 interactables: vec![],
17750 ledger: None,
17751 career: None,
17752 combat_fx: Vec::new(),
17753 ground_hazards: Vec::new(),
17754 property_plots: Vec::new(),
17755 terrain_overlays: Vec::new(),
17756 };
17757
17758 state.apply_tick_fields(&delta, 1);
17759
17760 assert_eq!(state.entities.len(), 1);
17761 assert!(state.player.is_some());
17762 assert_eq!(state.inventory.get("carrot"), Some(&3));
17763 assert_eq!(state.resource_nodes.len(), 1);
17764 }
17765
17766 #[test]
17767 fn tick_preserves_world_layers_when_delta_omits_them() {
17768 let mut state = sample_state();
17769 let delta = TickDelta {
17770 tick: 1,
17771 entities: state.entities.clone(),
17772 resource_nodes: vec![],
17773 ground_drops: vec![],
17774 placed_containers: vec![],
17775 buildings: vec![],
17776 doors: vec![],
17777 interior_map: None,
17778 npcs: vec![],
17779 inventory: vec![],
17780 blueprints: vec![],
17781 building_materials: vec![],
17782 world_clock: flatland_protocol::WorldClock::default(),
17783 combat: None,
17784 quest_log: vec![],
17785 hired_workers: Vec::new(),
17786 interactables: vec![],
17787 ledger: None,
17788 career: None,
17789 combat_fx: Vec::new(),
17790 ground_hazards: Vec::new(),
17791 property_plots: Vec::new(),
17792 terrain_overlays: Vec::new(),
17793 };
17794
17795 state.apply_tick_fields(&delta, 1);
17796
17797 assert_eq!(state.resource_nodes.len(), 1);
17798 assert_eq!(state.buildings.len(), 1);
17799 assert_eq!(state.doors.len(), 1);
17800 }
17801
17802 #[test]
17803 fn tick_updates_resource_nodes_when_server_sends_them() {
17804 let mut state = sample_state();
17805 let delta = TickDelta {
17806 tick: 1,
17807 entities: state.entities.clone(),
17808 resource_nodes: vec![ResourceNodeView {
17809 id: "oak-1".into(),
17810 label: "Oak".into(),
17811 x: 130.0,
17812 y: 128.0,
17813 z: 0.0,
17814 item_template: "oak_log".into(),
17815 state: ResourceNodeState::Cooldown,
17816 blocking: true,
17817 blocking_radius_m: 0.8,
17818 harvest_off: false,
17819 tile_id: None,
17820 yaw: 0.0,
17821 pitch: 0.0,
17822 roll: 0.0,
17823 draw_scale: 1.0,
17824 sprite_mode: None,
17825 growth_progress: None,
17826 presentation_state: None,
17827 channel_start_tick: None,
17828 channel_end_tick: None,
17829 harvest_drop_templates: vec![],
17830 }],
17831 buildings: vec![],
17832 doors: vec![],
17833 interior_map: None,
17834 npcs: vec![],
17835 inventory: vec![],
17836 blueprints: vec![],
17837 building_materials: vec![],
17838 world_clock: flatland_protocol::WorldClock::default(),
17839 ground_drops: vec![],
17840 placed_containers: vec![],
17841 combat: None,
17842 quest_log: vec![],
17843 hired_workers: Vec::new(),
17844 interactables: vec![],
17845 ledger: None,
17846 career: None,
17847 combat_fx: Vec::new(),
17848 ground_hazards: Vec::new(),
17849 property_plots: Vec::new(),
17850 terrain_overlays: Vec::new(),
17851 };
17852
17853 state.apply_tick_fields(&delta, 1);
17854
17855 assert!(matches!(
17856 state.resource_nodes[0].state,
17857 ResourceNodeState::Cooldown
17858 ));
17859 }
17860
17861 #[test]
17862 fn harvest_picker_keeps_welcome_nodes_after_aoi_tick() {
17863 let mut state = sample_state();
17864 let nearby = state.resource_nodes[0].clone();
17865 let mut far = nearby.clone();
17866 far.id = "far-oak".into();
17867 far.label = "Far Oak".into();
17868 far.x = 200.0;
17869 far.y = 200.0;
17870 state.replace_harvest_route_nodes(&[nearby.clone(), far.clone()]);
17871
17872 let delta = TickDelta {
17873 tick: 1,
17874 entities: state.entities.clone(),
17875 resource_nodes: vec![nearby],
17876 ground_drops: vec![],
17877 placed_containers: vec![],
17878 buildings: vec![],
17879 doors: vec![],
17880 interior_map: None,
17881 npcs: vec![],
17882 inventory: vec![],
17883 blueprints: vec![],
17884 building_materials: vec![],
17885 world_clock: flatland_protocol::WorldClock::default(),
17886 combat: None,
17887 quest_log: vec![],
17888 hired_workers: Vec::new(),
17889 interactables: vec![],
17890 ledger: None,
17891 career: None,
17892 combat_fx: Vec::new(),
17893 ground_hazards: Vec::new(),
17894 property_plots: Vec::new(),
17895 terrain_overlays: Vec::new(),
17896 };
17897 state.apply_tick_fields(&delta, 1);
17898
17899 assert_eq!(state.resource_nodes.len(), 1);
17900 let ids: Vec<_> = state
17901 .route_editor_node_candidates()
17902 .into_iter()
17903 .map(|n| n.id)
17904 .collect();
17905 assert!(ids.contains(&"oak-1".to_string()), "got {ids:?}");
17906 assert!(ids.contains(&"far-oak".to_string()), "got {ids:?}");
17907 }
17908
17909 #[test]
17910 fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
17911 let mut state = GameState {
17912 session_id: 1,
17913 entity_id: 1,
17914 character_id: None,
17915 tick: 0,
17916 chunk_rev: 0,
17917 content_rev: 0,
17918 publish_rev: 0,
17919 entities: vec![EntityState {
17920 id: 1,
17921 label: "You".into(),
17922 transform: Transform {
17923 position: WorldCoord::surface(4.5, 2.0),
17924 yaw: 0.0,
17925 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
17926 },
17927 vitals: None,
17928 attributes: None,
17929 skills: None,
17930 inside_building: Some("broker_hut".into()),
17931 tile_id: None,
17932 paperdoll_ref: None,
17933 draw_scale: 1.0,
17934 presentation_state: None,
17935 sprite_mode: None,
17936 progression_xp: None,
17937 combat_cues: vec![],
17938 statuses: vec![],
17939 }],
17940 player: None,
17941 resource_nodes: vec![],
17942 harvest_route_nodes: vec![],
17943 ground_drops: vec![],
17944 placed_containers: vec![],
17945 buildings: vec![BuildingView {
17946 id: "broker_hut".into(),
17947 label: "Broker".into(),
17948 x: 158.0,
17949 y: 124.0,
17950 width_m: 8.0,
17951 depth_m: 6.0,
17952 interior_blueprint: Some("broker_hut".into()),
17953 tags: vec![],
17954 market_boundary_zone_ids: vec![],
17955 market_max_volume: None,
17956 wall_set: None,
17957 roof_set: None,
17958 }],
17959 doors: vec![flatland_protocol::DoorView {
17960 id: "broker_hut_exit".into(),
17961 building_id: "broker_hut".into(),
17962 x: 4.3,
17963 y: 0.9,
17964 open: true,
17965 portal: Some("front".into()),
17966 locked: false,
17967 accessible: true,
17968 lock_id: None,
17969 }],
17970 interior_map: None,
17971 npcs: vec![flatland_protocol::NpcView {
17972 id: "ada_broker".into(),
17973 label: "Ada".into(),
17974 x: 4.5,
17975 y: 2.0,
17976 building_id: Some("broker_hut".into()),
17977 role: "broker".into(),
17978 entity_id: None,
17979 life_state: None,
17980 hp_pct: None,
17981 can_trade: true,
17982 buy_templates: vec!["lumber".into()],
17983 tile_id: None,
17984 behavior_state: None,
17985 presentation_state: None,
17986 sprite_mode: None,
17987 paperdoll_ref: None,
17988 draw_scale: 1.0,
17989 yaw: None,
17990 perception_fov_deg: None,
17991 perception_sight_m: None,
17992 perception_hear_m: None,
17993 quest_verbs: Vec::new(),
17994 }],
17995 blueprints: vec![],
17996 building_materials: vec![],
17997 world_x0: 0.0,
17998 world_y0: 0.0,
17999 world_width_m: 256.0,
18000 world_height_m: 256.0,
18001 terrain_zones: Vec::new(),
18002 z_platforms: Vec::new(),
18003 z_transitions: Vec::new(),
18004 z_bands_outdoor_backup: None,
18005 world_clock: flatland_protocol::WorldClock::default(),
18006 inventory: std::collections::HashMap::new(),
18007 inventory_hints: std::collections::HashMap::new(),
18008 item_catalog: std::collections::HashMap::new(),
18009 logs: VecDeque::new(),
18010 intents_sent: 0,
18011 ticks_received: 0,
18012 connected: true,
18013 disconnect_reason: None,
18014 show_stats: false,
18015 hud_log_hidden: false,
18016 show_equip_menu: false,
18017 equip_menu_index: 0,
18018 show_craft_menu: false,
18019 show_plot_build_menu: false,
18020 plot_build_focus_wall: true,
18021 plot_build_wall_index: 0,
18022 plot_build_roof_index: 0,
18023 craft_menu_index: 0,
18024 craft_batch_quantity: 1,
18025 craft_tab: CraftTab::Ready,
18026 craft_filter: String::new(),
18027 craft_filter_focused: false,
18028 craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
18029 show_shop_menu: false,
18030 shop_catalog: None,
18031 bank_panel: None,
18032 bank_menu_index: 0,
18033 bank_ui_mode: BankUiMode::Menu,
18034 storage_panel: None,
18035 market_panel: None,
18036 market_menu_index: 0,
18037 market_filter: String::new(),
18038 market_filter_focused: false,
18039 market_category_filter: None,
18040 market_buy_confirm: None,
18041 market_ui_mode: MarketUiMode::Browse,
18042 storage_menu_index: 0,
18043 storage_ui_mode: StorageUiMode::Menu,
18044 shop_tab: ShopTab::default(),
18045 shop_menu_index: 0,
18046 shop_quantity: 1,
18047 shop_trade_log: VecDeque::new(),
18048 show_npc_verb_menu: false,
18049 npc_verb_target: None,
18050 npc_verb_index: 0,
18051 npc_verb_notice: None,
18052 player_verbs: crate::social::PlayerVerbState::default(),
18053 social_chat: crate::social::SocialChatState::default(),
18054 trade_ui: crate::social::TradeUiState::default(),
18055 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
18056 show_npc_chat: false,
18057 npc_chat: None,
18058 show_inventory_menu: false,
18059 inventory_menu_index: 0,
18060 inventory_tab: InventoryTab::OnPerson,
18061 inventory_filter: String::new(),
18062 inventory_filter_focused: false,
18063 show_move_picker: false,
18064 show_rename_prompt: false,
18065 rename_plot_id: None,
18066 highlighted_plot_id: None,
18067 show_worker_rename: false,
18068 rename_buffer: String::new(),
18069 move_picker_index: 0,
18070 move_picker: None,
18071 show_grant_picker: false,
18072 grant_picker_index: 0,
18073 grant_picker: None,
18074 show_destroy_picker: false,
18075 destroy_confirm_pending: false,
18076 destroy_picker: None,
18077 combat_target: None,
18078 combat_target_label: None,
18079 ground_target: None,
18080 combat_fx: Vec::new(),
18081 ground_hazards: Vec::new(),
18082 property_zones: Vec::new(),
18083 tax_zones: Vec::new(),
18084 growth_zones: Vec::new(),
18085 biome_zones: Vec::new(),
18086 terrain_kind_nav: Vec::new(),
18087 property_plots: Vec::new(),
18088 property_plot_settings: None,
18089 claim_mode: None,
18090 relocate_mode: None,
18091 sell_plot_confirm: None,
18092 sell_plot_armed_at: None,
18093 show_plant_menu: false,
18094 plant_menu_index: 0,
18095 show_farm_access: false,
18096 farm_access_name_draft: String::new(),
18097 farm_access_discount_bps: 0,
18098 farm_access_index: 0,
18099 plant_quantity: 1,
18100 in_combat: false,
18101 auto_attack: true,
18102 combat_has_los: false,
18103 attack_cd_ticks: 0,
18104 gcd_ticks: 0,
18105 weapon_ability_id: "unarmed".into(),
18106 mainhand_template_id: None,
18107 mainhand_label: None,
18108 mainhand_instance_id: None,
18109 offhand_template_id: None,
18110 offhand_label: None,
18111 offhand_instance_id: None,
18112 mainhand_hand_slots: 1,
18113 defense: None,
18114 worn: BTreeMap::new(),
18115 carry_mass: 0.0,
18116 carry_mass_max: 0.0,
18117 encumbrance: flatland_protocol::EncumbranceState::Light,
18118 move_speed_mps: 0.0,
18119 move_speed_mult: 0.0,
18120 inventory_stacks: Vec::new(),
18121 keychain_stacks: Vec::new(),
18122 whisper_pouch_stacks: Vec::new(),
18123 combat_target_detail: None,
18124 statuses: Vec::new(),
18125 cast_progress: None,
18126 timed_channel: None,
18127 plot_build_offer: None,
18128 ability_cooldowns: Vec::new(),
18129 blocking_active: false,
18130 max_target_slots: 1,
18131 combat_slots: Vec::new(),
18132 rotation_presets: Vec::new(),
18133 known_abilities: Vec::new(),
18134 ability_meta: std::collections::HashMap::new(),
18135 ability_mastery: std::collections::HashMap::new(),
18136 hotbar: vec![None; 9],
18137 max_abilities_per_rotation: 0,
18138 show_loadout_menu: false,
18139 show_keychain_menu: false,
18140 keychain_menu_index: 0,
18141 show_rotation_editor: false,
18142 loadout_menu_index: 0,
18143 loadout_hotbar_slot: 1,
18144 loadout_ability_index: 0,
18145 loadout_focus_presets: false,
18146 rotation_editor: RotationEditorState::default(),
18147 harvest_in_progress: false,
18148 harvest_started_at: None,
18149 pending_craft_ack: None,
18150 craft_channel_blueprint_id: None,
18151 pending_worker_job_ack: None,
18152 attending_worker_instance_id: None,
18153 quest_log: Vec::new(),
18154 interactables: Vec::new(),
18155 ledger: None,
18156 career: None,
18157 character_sheet_tab: CharacterSheetTab::Character,
18158 ledger_period: LedgerPeriod::Day,
18159 show_quest_offer: false,
18160 pending_quest_offers: Vec::new(),
18161 quest_offer_index: 0,
18162 show_quest_menu: false,
18163 quest_menu_index: 0,
18164 quest_withdraw_confirm: false,
18165 hired_workers: Vec::new(),
18166 show_workers_menu: false,
18167 workers_menu_index: 0,
18168 worker_dismiss_confirmation: None,
18169 workers_menu_compact: false,
18170 worker_step_display: BTreeMap::new(),
18171 worker_error_display: BTreeMap::new(),
18172 worker_health_ring_until: BTreeMap::new(),
18173 pending_worker_hire_since: None,
18174 show_worker_give_picker: false,
18175 worker_give_picker_index: 0,
18176 worker_give_picker: None,
18177 show_worker_give_target_picker: false,
18178 worker_give_target_picker_index: 0,
18179 worker_give_target_picker: None,
18180 show_worker_take_picker: false,
18181 worker_take_picker_index: 0,
18182 worker_take_picker: None,
18183 show_worker_teach_picker: false,
18184 worker_teach_picker_index: 0,
18185 worker_teach_picker: None,
18186 worker_route_editor: None,
18187 progression_curve: None,
18188 };
18189 state.player = state.entities.first().cloned();
18190 assert_eq!(
18191 state.nearest_interact_target().as_deref(),
18192 Some("ada_broker")
18193 );
18194 }
18195
18196 #[test]
18197 fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
18198 let mut state = sample_state();
18199 state.placed_containers = vec![
18202 flatland_protocol::PlacedContainerView {
18203 id: "near".into(),
18204 template_id: "wooden_chest_small".into(),
18205 display_name: "Wooden Chest".into(),
18206 x: 130.0,
18207 y: 128.0,
18208 z: 0.0,
18209 locked: true,
18210 accessible: true,
18211 owner_character_id: None,
18212 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
18213 lock_id: None,
18214 capacity_volume: None,
18215 item_instance_id: Some(uuid::Uuid::from_u128(1)),
18216 tile_id: None,
18217 worker_lodging_capacity: None,
18218 blocking: false,
18219 blocking_radius_m: 0.0,
18220 building_id: None,
18221 },
18222 flatland_protocol::PlacedContainerView {
18223 id: "far".into(),
18224 template_id: "wooden_chest_small".into(),
18225 display_name: "Distant Chest".into(),
18226 x: 128.0 + CONTAINER_RANGE_M + 5.0,
18227 y: 128.0,
18228 z: 0.0,
18229 locked: false,
18230 accessible: true,
18231 owner_character_id: None,
18232 contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
18233 lock_id: None,
18234 capacity_volume: None,
18235 item_instance_id: Some(uuid::Uuid::from_u128(2)),
18236 tile_id: None,
18237 worker_lodging_capacity: None,
18238 blocking: false,
18239 blocking_radius_m: 0.0,
18240 building_id: None,
18241 },
18242 ];
18243
18244 let nearby = state.nearby_containers();
18245 assert_eq!(
18246 nearby.len(),
18247 1,
18248 "far chest must not appear once out of range"
18249 );
18250 assert_eq!(nearby[0].view.id, "near");
18251 assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
18252 assert!(nearby[0].rows[0].is_chest_shell);
18253
18254 state.placed_containers[0].accessible = false;
18257 let nearby = state.nearby_containers();
18258 assert_eq!(nearby.len(), 1);
18259 assert_eq!(nearby[0].rows.len(), 1);
18260 assert!(nearby[0].rows[0].is_chest_shell);
18261 }
18262
18263 #[test]
18264 fn chest_pickup_destinations_offer_person_and_worn_bag() {
18265 let mut state = sample_state();
18266 let back_id = uuid::Uuid::from_u128(42);
18267 state.worn.insert(
18268 BodySlot::Back,
18269 flatland_protocol::ItemStack {
18270 template_id: "travel_backpack".into(),
18271 quantity: 1,
18272 item_instance_id: Some(back_id),
18273 props: Default::default(),
18274 status_bindings: Vec::new(),
18275 contents: Vec::new(),
18276 display_name: Some("Travel Backpack".into()),
18277 category: Some("container".into()),
18278 base_mass: Some(2.5),
18279 base_volume: Some(12.0),
18280 capacity_volume: Some(80.0),
18281 stackable: Some(false),
18282 world_placeable: Some(false),
18283 worker_lodging_capacity: None,
18284 equip_slot: None,
18285 armor_physical: None,
18286 resists: vec![],
18287 hand_slots: None,
18288 listable: None,
18289 ..Default::default()
18290 },
18291 );
18292 let opts = state.chest_pickup_destinations("chest-1");
18293 assert!(matches!(
18294 opts.first().map(|o| &o.kind),
18295 Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
18296 ));
18297 assert!(opts.iter().any(|o| matches!(
18298 &o.kind,
18299 MoveOptionKind::PickupPlaced {
18300 nest_parent_instance_id: None,
18301 ..
18302 }
18303 )));
18304 assert!(opts.iter().any(|o| matches!(
18305 &o.kind,
18306 MoveOptionKind::PickupPlaced {
18307 nest_parent_instance_id: Some(id),
18308 ..
18309 } if *id == back_id
18310 )));
18311 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
18312 }
18313
18314 #[test]
18315 fn placed_container_public_label_hides_owner_custom_name() {
18316 let owner = uuid::Uuid::from_u128(99);
18317 let mut state = sample_state();
18318 state.character_id = Some(uuid::Uuid::from_u128(1));
18319 state.inventory_hints.insert(
18320 "wooden_chest_medium".into(),
18321 InventoryHint {
18322 display_name: "Medium Wooden Chest".into(),
18323 category: "container".into(),
18324 base_mass: None,
18325 base_volume: None,
18326 capacity_volume: None,
18327 stackable: false,
18328 listable: true,
18329 base_value_copper: None,
18330 },
18331 );
18332 let chest = flatland_protocol::PlacedContainerView {
18333 id: "c1".into(),
18334 template_id: "wooden_chest_medium".into(),
18335 display_name: "Barry's Loot #a3f2".into(),
18336 x: 128.0,
18337 y: 128.0,
18338 z: 0.0,
18339 locked: false,
18340 accessible: true,
18341 owner_character_id: Some(owner),
18342 contents: vec![],
18343 lock_id: None,
18344 capacity_volume: None,
18345 item_instance_id: None,
18346 tile_id: None,
18347 worker_lodging_capacity: None,
18348 blocking: false,
18349 blocking_radius_m: 0.0,
18350 building_id: None,
18351 };
18352 assert_eq!(
18353 state.placed_container_public_label(&chest),
18354 "Medium Wooden Chest"
18355 );
18356 state.character_id = Some(owner);
18357 assert_eq!(
18358 state.placed_container_public_label(&chest),
18359 "Barry's Loot #a3f2"
18360 );
18361 }
18362
18363 #[test]
18364 fn location_context_shows_crop_growth_percent_not_depleted() {
18365 let mut state = sample_state();
18366 state.player = state.entities.first().cloned();
18367 state.resource_nodes[0].label = "Carrot (growing)".into();
18368 state.resource_nodes[0].x = 128.2;
18369 state.resource_nodes[0].y = 128.0;
18370 state.resource_nodes[0].state = ResourceNodeState::Cooldown;
18371 state.resource_nodes[0].growth_progress = Some(0.47);
18372 let lines = state.location_context_lines();
18373 let line = lines
18374 .iter()
18375 .find(|l| l.text.contains("Carrot"))
18376 .map(|l| l.text.as_str())
18377 .unwrap_or("");
18378 assert!(
18379 line.contains("(growing, 47%)"),
18380 "expected growth percent, got: {line}"
18381 );
18382 assert!(
18383 !line.contains("depleted"),
18384 "growing crop should not show depleted: {line}"
18385 );
18386 }
18387
18388 #[test]
18389 fn resource_node_near_action_suffix_prefers_growth() {
18390 let node = ResourceNodeView {
18391 id: "crop".into(),
18392 label: "Wheat".into(),
18393 x: 0.0,
18394 y: 0.0,
18395 z: 0.0,
18396 item_template: "wheat".into(),
18397 state: ResourceNodeState::Cooldown,
18398 blocking: false,
18399 blocking_radius_m: 0.0,
18400 harvest_off: false,
18401 tile_id: None,
18402 yaw: 0.0,
18403 pitch: 0.0,
18404 roll: 0.0,
18405 draw_scale: 1.0,
18406 sprite_mode: None,
18407 growth_progress: Some(0.12),
18408 presentation_state: None,
18409 channel_start_tick: None,
18410 channel_end_tick: None,
18411 harvest_drop_templates: vec![],
18412 };
18413 assert_eq!(resource_node_near_action_suffix(&node), " (growing, 12%)");
18414 }
18415
18416 #[test]
18417 fn location_context_lists_nearby_resource_node() {
18418 let mut state = sample_state();
18419 state.player = state.entities.first().cloned();
18420 state.resource_nodes[0].x = 128.2;
18421 state.resource_nodes[0].y = 128.0;
18422 let lines = state.location_context_lines();
18423 assert!(
18424 lines
18425 .iter()
18426 .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
18427 "expected resource node in context: {:?}",
18428 lines
18429 );
18430 }
18431
18432 #[test]
18433 fn quest_board_usable_within_board_radius() {
18434 let mut state = sample_state();
18435 state.player = state.entities.first().cloned();
18436 state.interactables = vec![flatland_protocol::InteractableView {
18437 id: "board-1".into(),
18438 kind: "quest_board".into(),
18439 label: "Town Quest Board".into(),
18440 x: 130.5,
18441 y: 128.0,
18442 z: 0.0,
18443 board_id: Some("starter_town_board".into()),
18444 }];
18445 assert_eq!(
18447 state.nearest_interact_target().as_deref(),
18448 Some("board-1"),
18449 "quest board should be selectable at ~2.5m"
18450 );
18451 let lines = state.location_context_lines();
18452 assert!(
18453 lines
18454 .iter()
18455 .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
18456 "HUD should advertise f when board is in range: {:?}",
18457 lines
18458 );
18459 }
18460
18461 #[test]
18462 fn quest_board_keeps_multiple_offers() {
18463 let mut state = sample_state();
18464 let offer = |id: &str, title: &str| flatland_protocol::QuestOffer {
18465 quest_id: id.into(),
18466 title: title.into(),
18467 description: format!("{title} desc"),
18468 step_count: 2,
18469 };
18470 state.push_quest_offer(offer("ada_goblin_hunt", "Goblin Trouble"));
18471 state.push_quest_offer(offer("daily_20695_1", "Town board: Deer threat"));
18472 state.push_quest_offer(offer("ada_goblin_hunt", "Goblin Trouble"));
18473 assert_eq!(state.pending_quest_offers.len(), 2);
18474 assert_eq!(
18475 state.selected_quest_offer().unwrap().quest_id,
18476 "ada_goblin_hunt"
18477 );
18478 state.move_quest_offer_selection(1);
18479 assert_eq!(
18480 state.selected_quest_offer().unwrap().quest_id,
18481 "daily_20695_1"
18482 );
18483 state.remove_quest_offer("daily_20695_1");
18484 assert_eq!(state.pending_quest_offers.len(), 1);
18485 assert!(state.show_quest_offer);
18486 state.remove_quest_offer("ada_goblin_hunt");
18487 assert!(!state.show_quest_offer);
18488 assert!(state.pending_quest_offers.is_empty());
18489 }
18490
18491 #[test]
18492 fn inventory_selectable_rows_excludes_equipped_shells_but_keeps_bag_contents() {
18493 let mut state = sample_state();
18494 state.worn.insert(
18495 BodySlot::Back,
18496 flatland_protocol::ItemStack {
18497 template_id: "travel_backpack".into(),
18498 quantity: 1,
18499 item_instance_id: Some(uuid::Uuid::from_u128(3)),
18500 props: Default::default(),
18501 status_bindings: Vec::new(),
18502 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
18503 display_name: None,
18504 category: None,
18505 base_mass: None,
18506 base_volume: None,
18507 capacity_volume: None,
18508 stackable: None,
18509 world_placeable: None,
18510 worker_lodging_capacity: None,
18511 equip_slot: None,
18512 armor_physical: None,
18513 resists: vec![],
18514 hand_slots: None,
18515 listable: None,
18516 ..Default::default()
18517 },
18518 );
18519 state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
18520 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18521 id: "chest-1".into(),
18522 template_id: "wooden_chest_small".into(),
18523 display_name: "Wooden Chest".into(),
18524 x: 129.0,
18525 y: 128.0,
18526 z: 0.0,
18527 locked: false,
18528 accessible: true,
18529 owner_character_id: None,
18530 contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
18531 lock_id: None,
18532 capacity_volume: None,
18533 item_instance_id: Some(uuid::Uuid::from_u128(4)),
18534 tile_id: None,
18535 worker_lodging_capacity: None,
18536 blocking: false,
18537 blocking_radius_m: 0.0,
18538 building_id: None,
18539 }];
18540
18541 state.inventory_tab = InventoryTab::OnPerson;
18542 let rows = state.inventory_selectable_rows();
18543 let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
18544 assert_eq!(
18545 sections,
18546 vec![
18547 InventorySection::Person, InventorySection::Person, ]
18550 );
18551 assert_eq!(rows[0].stack.template_id, "iron_ore");
18552 assert_eq!(rows[0].depth, 0);
18553 assert!(!rows[0].is_equip_shell);
18554 assert_eq!(rows[1].stack.template_id, "lumber");
18555
18556 let lines = state.inventory_browser_lines();
18557 assert!(lines.iter().any(|l| matches!(
18558 l,
18559 InventoryBrowserLine::Section(s) if s.contains("carried bags")
18560 )));
18561 assert!(lines.iter().any(|l| matches!(
18562 l,
18563 InventoryBrowserLine::Item { text, .. } if text.contains("iron_ore")
18564 )));
18565 assert!(!lines.iter().any(|l| matches!(
18566 l,
18567 InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
18568 )));
18569 assert!(!lines.iter().any(|l| matches!(
18570 l,
18571 InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
18572 )));
18573
18574 state.inventory_tab = InventoryTab::Nearby;
18575 let nearby_rows = state.inventory_selectable_rows();
18576 assert_eq!(nearby_rows.len(), 2);
18577 assert!(nearby_rows[0].is_chest_shell);
18578 assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
18579 let nearby_lines = state.inventory_browser_lines();
18580 assert!(nearby_lines.iter().any(|l| matches!(
18581 l,
18582 InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
18583 )));
18584 }
18585
18586 #[test]
18587 fn give_worker_notice_does_not_put_item_back_in_bag() {
18588 let mut state = sample_state();
18589 let id = uuid::Uuid::from_u128(42);
18590 let mut saw = flatland_protocol::ItemStack::simple("handsaw", 1);
18591 saw.item_instance_id = Some(id);
18592 saw.display_name = Some("Handsaw".into());
18593 state.sync_inventory_from_stacks(&[saw]);
18594 assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 1);
18595
18596 state.remove_carried_instance(id, None);
18597 assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 0);
18598 assert!(state.inventory_stacks.is_empty());
18599
18600 state.apply_interaction_notice(&flatland_protocol::InteractionNotice {
18601 target_id: "worker-1".into(),
18602 message: "Gave 1x Handsaw to Laborer".into(),
18603 coins_delta: 0,
18604 inventory_delta: vec![flatland_protocol::ItemStack::simple("handsaw", 1)],
18605 });
18606 assert_eq!(
18607 state.inventory.get("handsaw").copied().unwrap_or(0),
18608 0,
18609 "Gave notice must not restore the handed stack"
18610 );
18611 }
18612
18613 #[test]
18614 fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
18615 let mut state = sample_state();
18616 let back_id = uuid::Uuid::from_u128(5);
18617 state.worn.insert(
18618 BodySlot::Back,
18619 flatland_protocol::ItemStack {
18620 template_id: "travel_backpack".into(),
18621 quantity: 1,
18622 item_instance_id: Some(back_id),
18623 props: Default::default(),
18624 status_bindings: Vec::new(),
18625 contents: Vec::new(),
18626 display_name: None,
18627 category: Some("container".into()),
18628 base_mass: None,
18629 base_volume: None,
18630 capacity_volume: Some(80.0),
18631 stackable: None,
18632 world_placeable: None,
18633 worker_lodging_capacity: None,
18634 equip_slot: None,
18635 armor_physical: None,
18636 resists: vec![],
18637 hand_slots: None,
18638 listable: None,
18639 ..Default::default()
18640 },
18641 );
18642 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18643 id: "chest-1".into(),
18644 template_id: "wooden_chest_small".into(),
18645 display_name: "Wooden Chest".into(),
18646 x: 129.0,
18647 y: 128.0,
18648 z: 0.0,
18649 locked: false,
18650 accessible: true,
18651 owner_character_id: None,
18652 contents: Vec::new(),
18653 lock_id: None,
18654 capacity_volume: None,
18655 item_instance_id: Some(uuid::Uuid::from_u128(6)),
18656 tile_id: None,
18657 worker_lodging_capacity: None,
18658 blocking: false,
18659 blocking_radius_m: 0.0,
18660 building_id: None,
18661 }];
18662
18663 let opts = state.move_destinations_for(
18666 &flatland_protocol::InventoryLocation::Root,
18667 None,
18668 None,
18669 "lumber",
18670 );
18671 assert!(!opts.iter().any(|o| matches!(
18672 &o.kind,
18673 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
18674 )));
18675 assert!(opts.iter().any(|o| matches!(
18676 &o.kind,
18677 MoveOptionKind::Move { location, parent_instance_id, .. }
18678 if *location == flatland_protocol::InventoryLocation::Worn {
18679 slot: BodySlot::Back,
18680 } && *parent_instance_id == Some(back_id)
18681 )));
18682 assert!(opts.iter().any(|o| matches!(
18683 &o.kind,
18684 MoveOptionKind::Move { location, .. }
18685 if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
18686 )));
18687 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
18688 assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
18689
18690 let from_backpack = flatland_protocol::InventoryLocation::Worn {
18694 slot: BodySlot::Back,
18695 };
18696 let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
18697 assert!(!opts.iter().any(|o| matches!(
18698 &o.kind,
18699 MoveOptionKind::Move { location, parent_instance_id, .. }
18700 if *location == from_backpack && *parent_instance_id == Some(back_id)
18701 )));
18702 assert!(opts.iter().any(|o| matches!(
18703 &o.kind,
18704 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
18705 )));
18706 }
18707
18708 #[test]
18709 fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
18710 let mut state = sample_state();
18711 state.worn.insert(
18714 BodySlot::Waist,
18715 flatland_protocol::ItemStack {
18716 template_id: "simple_belt".into(),
18717 quantity: 1,
18718 item_instance_id: Some(uuid::Uuid::from_u128(10)),
18719 props: Default::default(),
18720 status_bindings: Vec::new(),
18721 contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
18722 display_name: None,
18723 category: Some("container".into()),
18724 base_mass: None,
18725 base_volume: None,
18726 capacity_volume: None,
18727 stackable: None,
18728 world_placeable: None,
18729 worker_lodging_capacity: None,
18730 equip_slot: None,
18731 armor_physical: None,
18732 resists: vec![],
18733 hand_slots: None,
18734 listable: None,
18735 ..Default::default()
18736 },
18737 );
18738 state.worn.insert(
18739 BodySlot::Head,
18740 flatland_protocol::ItemStack {
18741 template_id: "cloth_cap".into(),
18742 quantity: 1,
18743 item_instance_id: Some(uuid::Uuid::from_u128(11)),
18744 props: Default::default(),
18745 status_bindings: Vec::new(),
18746 contents: Vec::new(),
18747 display_name: None,
18748 category: Some("armor".into()),
18749 base_mass: None,
18750 base_volume: None,
18751 capacity_volume: None,
18752 stackable: None,
18753 world_placeable: None,
18754 worker_lodging_capacity: None,
18755 equip_slot: None,
18756 armor_physical: None,
18757 resists: vec![],
18758 hand_slots: None,
18759 listable: None,
18760 ..Default::default()
18761 },
18762 );
18763 state.worn.insert(
18764 BodySlot::Back,
18765 flatland_protocol::ItemStack {
18766 template_id: "travel_backpack".into(),
18767 quantity: 1,
18768 item_instance_id: Some(uuid::Uuid::from_u128(12)),
18769 props: Default::default(),
18770 status_bindings: Vec::new(),
18771 contents: Vec::new(),
18772 display_name: None,
18773 category: Some("container".into()),
18774 base_mass: None,
18775 base_volume: None,
18776 capacity_volume: None,
18777 stackable: None,
18778 world_placeable: None,
18779 worker_lodging_capacity: None,
18780 equip_slot: None,
18781 armor_physical: None,
18782 resists: vec![],
18783 hand_slots: None,
18784 listable: None,
18785 ..Default::default()
18786 },
18787 );
18788
18789 let rows = state.worn_rows();
18790 assert_eq!(rows.len(), 4);
18792 assert_eq!(rows[0].stack.template_id, "cloth_cap");
18793 assert!(rows[0].is_equip_shell);
18794 assert_eq!(rows[1].stack.template_id, "travel_backpack");
18795 assert!(rows[1].is_equip_shell);
18796 assert_eq!(rows[2].stack.template_id, "simple_belt");
18797 assert!(rows[2].is_equip_shell);
18798 assert_eq!(rows[3].stack.template_id, "leather_pouch");
18799 assert_eq!(rows[3].depth, 1);
18800 assert!(!rows[3].is_equip_shell);
18801 }
18802
18803 #[test]
18804 fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
18805 let mut state = sample_state();
18806 state.worn.insert(
18807 BodySlot::Waist,
18808 flatland_protocol::ItemStack {
18809 template_id: "simple_belt".into(),
18810 quantity: 1,
18811 item_instance_id: Some(uuid::Uuid::from_u128(20)),
18812 props: Default::default(),
18813 status_bindings: Vec::new(),
18814 contents: Vec::new(),
18815 display_name: Some("Simple Belt".into()),
18816 category: Some("container".into()),
18817 base_mass: None,
18818 base_volume: None,
18819 capacity_volume: None,
18820 stackable: None,
18821 world_placeable: None,
18822 worker_lodging_capacity: None,
18823 equip_slot: None,
18824 armor_physical: None,
18825 resists: vec![],
18826 hand_slots: None,
18827 listable: None,
18828 ..Default::default()
18829 },
18830 );
18831 state.worn.insert(
18832 BodySlot::Head,
18833 flatland_protocol::ItemStack {
18834 template_id: "cloth_cap".into(),
18835 quantity: 1,
18836 item_instance_id: Some(uuid::Uuid::from_u128(21)),
18837 props: Default::default(),
18838 status_bindings: Vec::new(),
18839 contents: Vec::new(),
18840 display_name: Some("Cloth Cap".into()),
18841 category: Some("armor".into()),
18842 base_mass: None,
18843 base_volume: None,
18844 capacity_volume: None,
18845 stackable: None,
18846 world_placeable: None,
18847 worker_lodging_capacity: None,
18848 equip_slot: None,
18849 armor_physical: None,
18850 resists: vec![],
18851 hand_slots: None,
18852 listable: None,
18853 ..Default::default()
18854 },
18855 );
18856
18857 let opts = state.move_destinations_for(
18858 &flatland_protocol::InventoryLocation::Root,
18859 None,
18860 None,
18861 "leather_pouch",
18862 );
18863 assert!(
18864 opts.iter().any(|o| matches!(
18865 &o.kind,
18866 MoveOptionKind::Move { location, .. }
18867 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18868 )),
18869 "belt loop must be offered when moving a pouch"
18870 );
18871 assert!(
18872 !opts.iter().any(|o| matches!(
18873 &o.kind,
18874 MoveOptionKind::Move { location, .. }
18875 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
18876 )),
18877 "armor slots can't hold other items and must not appear as move destinations"
18878 );
18879 let belt_opt = opts
18880 .iter()
18881 .find(|o| matches!(
18882 &o.kind,
18883 MoveOptionKind::Move { location, .. }
18884 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18885 ))
18886 .unwrap();
18887 assert!(belt_opt.label.contains("belt loop"));
18888
18889 let opts = state.move_destinations_for(
18890 &flatland_protocol::InventoryLocation::Root,
18891 None,
18892 None,
18893 "lumber",
18894 );
18895 assert!(
18896 !opts.iter().any(|o| o.label.contains("belt loop")),
18897 "loose materials must not target the belt shell — only nested pouches"
18898 );
18899 }
18900
18901 #[test]
18902 fn move_destinations_for_offers_dimensional_pouch_on_belt() {
18903 let mut state = sample_state();
18904 let belt_id = uuid::Uuid::from_u128(30);
18905 let pouch_id = uuid::Uuid::from_u128(31);
18906 state.worn.insert(
18907 BodySlot::Waist,
18908 flatland_protocol::ItemStack {
18909 template_id: "simple_belt".into(),
18910 quantity: 1,
18911 item_instance_id: Some(belt_id),
18912 props: Default::default(),
18913 status_bindings: Vec::new(),
18914 world_placeable: None,
18915 worker_lodging_capacity: None,
18916 equip_slot: None,
18917 armor_physical: None,
18918 resists: vec![],
18919 hand_slots: None,
18920 contents: vec![flatland_protocol::ItemStack {
18921 template_id: "dimensional_pouch".into(),
18922 quantity: 1,
18923 item_instance_id: Some(pouch_id),
18924 props: Default::default(),
18925 status_bindings: Vec::new(),
18926 contents: Vec::new(),
18927 display_name: Some("Dimensional Pouch".into()),
18928 category: Some("container".into()),
18929 base_mass: None,
18930 base_volume: None,
18931 capacity_volume: Some(200.0),
18932 stackable: None,
18933 world_placeable: None,
18934 worker_lodging_capacity: None,
18935 equip_slot: None,
18936 armor_physical: None,
18937 resists: vec![],
18938 hand_slots: None,
18939 listable: None,
18940 ..Default::default()
18941 }],
18942 display_name: Some("Simple Belt".into()),
18943 category: Some("container".into()),
18944 base_mass: None,
18945 base_volume: None,
18946 capacity_volume: None,
18947 stackable: None,
18948 listable: None,
18949 ..Default::default()
18950 },
18951 );
18952
18953 let opts = state.move_destinations_for(
18954 &flatland_protocol::InventoryLocation::Root,
18955 None,
18956 None,
18957 "iron_ore",
18958 );
18959 assert!(
18960 opts.iter().any(|o| matches!(
18961 &o.kind,
18962 MoveOptionKind::Move {
18963 location,
18964 parent_instance_id,
18965 ..
18966 } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18967 && *parent_instance_id == Some(pouch_id)
18968 )),
18969 "dimensional pouch clipped on belt must accept loose items"
18970 );
18971 assert!(
18972 opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
18973 "destination label should name the pouch"
18974 );
18975 }
18976
18977 #[test]
18978 fn container_volume_label_on_placed_chest_shell() {
18979 let mut state = sample_state();
18980 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18981 id: "chest-1".into(),
18982 template_id: "wooden_chest_small".into(),
18983 display_name: "Camp Chest".into(),
18984 x: 129.0,
18985 y: 128.0,
18986 z: 0.0,
18987 locked: false,
18988 accessible: true,
18989 owner_character_id: None,
18990 contents: vec![flatland_protocol::ItemStack {
18991 template_id: "iron_ore".into(),
18992 quantity: 2,
18993 item_instance_id: None,
18994 props: Default::default(),
18995 status_bindings: Vec::new(),
18996 contents: Vec::new(),
18997 display_name: None,
18998 category: None,
18999 base_mass: None,
19000 base_volume: Some(2.0),
19001 capacity_volume: None,
19002 stackable: None,
19003 world_placeable: None,
19004 worker_lodging_capacity: None,
19005 equip_slot: None,
19006 armor_physical: None,
19007 resists: vec![],
19008 hand_slots: None,
19009 listable: None,
19010 ..Default::default()
19011 }],
19012 lock_id: None,
19013 capacity_volume: Some(60.0),
19014 item_instance_id: Some(uuid::Uuid::from_u128(4)),
19015 tile_id: None,
19016 worker_lodging_capacity: None,
19017 blocking: false,
19018 blocking_radius_m: 0.0,
19019 building_id: None,
19020 }];
19021 let nearby = state.nearby_containers();
19022 let label = state.container_volume_label(&nearby[0].rows[0]);
19023 assert!(
19024 label.contains("vol 4/60"),
19025 "expected used/cap in label, got {label}"
19026 );
19027 assert!(
19028 label.contains("56 free"),
19029 "expected free space, got {label}"
19030 );
19031 }
19032
19033 #[test]
19034 fn key_pair_chest_label_from_placed_lock_id() {
19035 let mut state = sample_state();
19036 let owner = uuid::Uuid::from_u128(77);
19037 state.character_id = Some(owner);
19038 let lock = uuid::Uuid::from_u128(99).to_string();
19039 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
19040 id: "chest-1".into(),
19041 template_id: "wooden_chest_small".into(),
19042 display_name: "Barry's Loot #a3f2".into(),
19043 x: 129.0,
19044 y: 128.0,
19045 z: 0.0,
19046 locked: true,
19047 accessible: true,
19048 owner_character_id: Some(owner),
19049 contents: Vec::new(),
19050 lock_id: Some(lock.clone()),
19051 capacity_volume: None,
19052 item_instance_id: Some(uuid::Uuid::from_u128(4)),
19053 tile_id: None,
19054 worker_lodging_capacity: None,
19055 blocking: false,
19056 blocking_radius_m: 0.0,
19057 building_id: None,
19058 }];
19059 let key_id = uuid::Uuid::from_u128(5);
19060 let key = flatland_protocol::ItemStack {
19061 template_id: KEY_TEMPLATE.into(),
19062 quantity: 1,
19063 item_instance_id: Some(key_id),
19064 props: BTreeMap::from([
19065 (PROP_OPENS_LOCK_ID.into(), lock),
19066 (
19067 PROP_OPENS_CONTAINER_NAME.into(),
19068 "Barry's Loot #a3f2".into(),
19069 ),
19070 ]),
19071 status_bindings: Vec::new(),
19072 contents: Vec::new(),
19073 display_name: Some("Container Key".into()),
19074 category: Some("key".into()),
19075 base_mass: None,
19076 base_volume: None,
19077 capacity_volume: None,
19078 stackable: None,
19079 world_placeable: None,
19080 worker_lodging_capacity: None,
19081 equip_slot: None,
19082 armor_physical: None,
19083 resists: vec![],
19084 hand_slots: None,
19085 listable: None,
19086 ..Default::default()
19087 };
19088 state.inventory_stacks = vec![key.clone()];
19089 assert_eq!(
19090 state.key_pair_chest_label(&key).as_deref(),
19091 Some("Barry's Loot #a3f2")
19092 );
19093 assert!(state.key_drop_blocked(&key));
19094 }
19095
19096 #[test]
19097 fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
19098 let mut state = sample_state();
19099 let lock = uuid::Uuid::from_u128(101).to_string();
19100 let key = flatland_protocol::ItemStack {
19101 template_id: KEY_TEMPLATE.into(),
19102 quantity: 1,
19103 item_instance_id: Some(uuid::Uuid::from_u128(7)),
19104 props: BTreeMap::from([
19105 (PROP_OPENS_LOCK_ID.into(), lock),
19106 (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
19107 ]),
19108 status_bindings: Vec::new(),
19109 contents: Vec::new(),
19110 display_name: None,
19111 category: Some("key".into()),
19112 base_mass: None,
19113 base_volume: None,
19114 capacity_volume: None,
19115 stackable: None,
19116 world_placeable: None,
19117 worker_lodging_capacity: None,
19118 equip_slot: None,
19119 armor_physical: None,
19120 resists: vec![],
19121 hand_slots: None,
19122 listable: None,
19123 ..Default::default()
19124 };
19125 state.placed_containers.clear();
19126 assert_eq!(
19127 state.key_pair_chest_label(&key).as_deref(),
19128 Some("Camp Stash")
19129 );
19130 }
19131
19132 #[test]
19133 fn key_drop_allowed_when_paired_chest_unlocked() {
19134 let mut state = sample_state();
19135 let lock = uuid::Uuid::from_u128(100).to_string();
19136 let key_id = uuid::Uuid::from_u128(6);
19137 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
19138 id: "chest-1".into(),
19139 template_id: "wooden_chest_small".into(),
19140 display_name: "Camp Chest".into(),
19141 x: 129.0,
19142 y: 128.0,
19143 z: 0.0,
19144 locked: false,
19145 accessible: true,
19146 owner_character_id: None,
19147 contents: Vec::new(),
19148 lock_id: Some(lock.clone()),
19149 capacity_volume: None,
19150 item_instance_id: None,
19151 tile_id: None,
19152 worker_lodging_capacity: None,
19153 blocking: false,
19154 blocking_radius_m: 0.0,
19155 building_id: None,
19156 }];
19157 let key = flatland_protocol::ItemStack {
19158 template_id: KEY_TEMPLATE.into(),
19159 quantity: 1,
19160 item_instance_id: Some(key_id),
19161 props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
19162 status_bindings: Vec::new(),
19163 contents: Vec::new(),
19164 display_name: None,
19165 category: Some("key".into()),
19166 base_mass: None,
19167 base_volume: None,
19168 capacity_volume: None,
19169 stackable: None,
19170 world_placeable: None,
19171 worker_lodging_capacity: None,
19172 equip_slot: None,
19173 armor_physical: None,
19174 resists: vec![],
19175 hand_slots: None,
19176 listable: None,
19177 ..Default::default()
19178 };
19179 state.inventory_stacks = vec![key.clone()];
19180 assert!(!state.key_drop_blocked(&key));
19181 let opts = state.move_destinations_for(
19182 &flatland_protocol::InventoryLocation::Root,
19183 None,
19184 Some(key_id),
19185 KEY_TEMPLATE,
19186 );
19187 assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
19188 }
19189
19190 #[test]
19191 fn combat_hud_refreshes_progression_xp_when_entity_stale() {
19192 use flatland_protocol::{CombatHud, ProgressionCurve, ProgressionXp};
19193
19194 let mut state = sample_state();
19195 let curve = ProgressionCurve::default();
19196 let bootstrap =
19197 ProgressionXp::bootstrap_new(curve.baseline_display, curve.xp_base, curve.xp_growth);
19198 let mut fresh = bootstrap.clone();
19199 fresh.strength += 0.08;
19200 if let Some(player) = state.player.as_mut() {
19201 player.progression_xp = Some(bootstrap);
19202 }
19203
19204 let combat = CombatHud {
19205 progression_xp: Some(fresh.clone()),
19206 progression_baseline: curve.baseline_display,
19207 progression_xp_base: curve.xp_base,
19208 progression_xp_growth: curve.xp_growth,
19209 attributes: state.player.as_ref().and_then(|p| p.attributes),
19210 skills: state.player.as_ref().and_then(|p| p.skills.clone()),
19211 ..CombatHud::default()
19212 };
19213 state.apply_combat_hud(&combat);
19214
19215 let xp = state
19216 .player
19217 .as_ref()
19218 .and_then(|p| p.progression_xp.as_ref())
19219 .expect("xp");
19220 assert!((xp.strength - fresh.strength).abs() < 0.001);
19221 assert!(state.progression_curve.is_some());
19222 }
19223
19224 #[test]
19225 fn combat_hud_syncs_known_abilities_and_hotbar() {
19226 use flatland_protocol::CombatHud;
19227
19228 let mut state = sample_state();
19229 let combat = CombatHud {
19230 known_abilities: vec!["unarmed".into(), "fireball".into()],
19231 hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
19232 max_abilities_per_rotation: 4,
19233 ability_id: "short_sword_slash".into(),
19234 ..CombatHud::default()
19235 };
19236 state.apply_combat_hud(&combat);
19237
19238 assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
19239 assert_eq!(state.hotbar_ability(1), Some("fireball"));
19240 assert_eq!(state.hotbar_ability(2), None);
19241 assert_eq!(state.hotbar_ability(3), Some("unarmed"));
19242 assert_eq!(state.max_abilities_per_rotation, 4);
19243 let choices = state.loadout_ability_choices();
19244 assert!(choices.iter().any(|a| a == "short_sword_slash"));
19245 assert!(choices.iter().any(|a| a == "fireball"));
19246 }
19247
19248 #[test]
19249 fn loadout_hotbar_choices_include_inventory_consumables() {
19250 let mut state = sample_state();
19251 state.known_abilities = vec!["unarmed".into()];
19252 state.weapon_ability_id = "unarmed".into();
19253 state.inventory_stacks = vec![flatland_protocol::ItemStack {
19254 template_id: "empty_bottle".into(),
19255 quantity: 1,
19256 item_instance_id: Some(uuid::Uuid::from_u128(9)),
19257 display_name: Some("Glass Bottle of Water".into()),
19258 category: Some("container".into()),
19259 props: [
19260 ("serving".into(), "1".into()),
19261 ("liquid_vessel".into(), "1".into()),
19262 ("serving_holds".into(), "liquid".into()),
19263 ]
19264 .into_iter()
19265 .collect(),
19266 ..Default::default()
19267 }];
19268 state.inventory.insert("empty_bottle".into(), 1);
19269 state.inventory_hints.insert(
19270 "empty_bottle".into(),
19271 InventoryHint {
19272 display_name: "Glass Bottle".into(),
19273 category: "container".into(),
19274 ..Default::default()
19275 },
19276 );
19277
19278 let choices = state.loadout_hotbar_choices();
19279 assert!(choices.iter().any(|c| c.binding == "unarmed"));
19280 let water = choices
19281 .iter()
19282 .find(|c| c.binding == "item:empty_bottle")
19283 .expect("serving bottle binding");
19284 assert_eq!(water.meta.as_deref(), Some("use"));
19285 assert!(water.label.contains("Glass Bottle of Water"));
19286 assert_eq!(state.hotbar_slot_label(1), None, "unbound until set");
19287 state.hotbar = vec![None, None, None, None, Some("item:empty_bottle".into())];
19288 assert_eq!(
19289 state.hotbar_slot_label(5).as_deref(),
19290 Some("Glass Bottle×1")
19291 );
19292 }
19293
19294 #[test]
19295 fn loadout_hotbar_choices_exclude_blueprint_scrolls() {
19296 let mut state = sample_state();
19297 state.known_abilities = vec!["unarmed".into()];
19298 state.weapon_ability_id = "unarmed".into();
19299 state.inventory_stacks = vec![
19300 flatland_protocol::ItemStack {
19301 template_id: "carrot".into(),
19302 quantity: 2,
19303 display_name: Some("Wild Carrot".into()),
19304 category: Some("consumable".into()),
19305 ..Default::default()
19306 },
19307 flatland_protocol::ItemStack {
19308 template_id: "blueprint_dimensional_pouch".into(),
19309 quantity: 1,
19310 display_name: Some("Blueprint — Dimensional Pouch".into()),
19311 category: Some("consumable".into()),
19312 props: [("teaches_blueprint".into(), "craft_dimensional_pouch".into())]
19313 .into_iter()
19314 .collect(),
19315 ..Default::default()
19316 },
19317 ];
19318 state.inventory.insert("carrot".into(), 2);
19319 state.inventory.insert("blueprint_dimensional_pouch".into(), 1);
19320 state.inventory_hints.insert(
19321 "carrot".into(),
19322 InventoryHint {
19323 display_name: "Wild Carrot".into(),
19324 category: "consumable".into(),
19325 ..Default::default()
19326 },
19327 );
19328 state.inventory_hints.insert(
19329 "blueprint_dimensional_pouch".into(),
19330 InventoryHint {
19331 display_name: "Blueprint — Dimensional Pouch".into(),
19332 category: "consumable".into(),
19333 ..Default::default()
19334 },
19335 );
19336
19337 let choices = state.loadout_hotbar_choices();
19338 assert!(choices.iter().any(|c| c.binding == "item:carrot"));
19339 assert!(
19340 choices
19341 .iter()
19342 .all(|c| c.binding != "item:blueprint_dimensional_pouch"),
19343 "recipe scrolls must not appear on the hotbar picker: {choices:?}"
19344 );
19345 }
19346
19347 #[test]
19348 fn storage_store_options_excludes_hand_equipped() {
19349 let mut state = sample_state();
19350 let sword_id = uuid::Uuid::from_u128(11);
19351 let ore_id = uuid::Uuid::from_u128(22);
19352 state.inventory_stacks = vec![
19353 flatland_protocol::ItemStack {
19354 template_id: "short_sword".into(),
19355 quantity: 1,
19356 item_instance_id: Some(sword_id),
19357 display_name: Some("Short Sword".into()),
19358 category: Some("weapon".into()),
19359 ..Default::default()
19360 },
19361 flatland_protocol::ItemStack {
19362 template_id: "iron_ore".into(),
19363 quantity: 5,
19364 item_instance_id: Some(ore_id),
19365 display_name: Some("Iron Ore".into()),
19366 category: Some("resource".into()),
19367 ..Default::default()
19368 },
19369 ];
19370 state.mainhand_template_id = Some("short_sword".into());
19371 state.mainhand_instance_id = Some(sword_id);
19372
19373 let opts = state.storage_store_options();
19374 assert_eq!(opts.len(), 1);
19375 assert_eq!(opts[0].item_instance_id, ore_id);
19376 assert!(state.hand_equipped_instance_ids().contains(&sword_id));
19377 }
19378
19379 #[test]
19380 fn loose_consumable_move_picker_offers_use_and_storage() {
19381 let mut state = sample_state();
19382 let inst = uuid::Uuid::from_u128(77);
19383 state.inventory_stacks = vec![flatland_protocol::ItemStack {
19384 template_id: "carrot".into(),
19385 quantity: 2,
19386 item_instance_id: Some(inst),
19387 props: Default::default(),
19388 status_bindings: Vec::new(),
19389 contents: Vec::new(),
19390 display_name: Some("Wild Carrot".into()),
19391 category: Some("consumable".into()),
19392 base_mass: None,
19393 base_volume: None,
19394 capacity_volume: None,
19395 stackable: Some(true),
19396 world_placeable: None,
19397 worker_lodging_capacity: None,
19398 equip_slot: None,
19399 armor_physical: None,
19400 resists: vec![],
19401 hand_slots: None,
19402 listable: None,
19403 ..Default::default()
19404 }];
19405 state.inventory_hints.insert(
19406 "carrot".into(),
19407 InventoryHint {
19408 display_name: "Wild Carrot".into(),
19409 category: "consumable".into(),
19410 base_mass: Some(0.15),
19411 base_volume: Some(0.3),
19412 capacity_volume: None,
19413 stackable: true,
19414 listable: true,
19415 base_value_copper: None,
19416 },
19417 );
19418 state.show_inventory_menu = true;
19419 state.inventory_menu_index = 0;
19420
19421 let row = state.inventory_selected_row().expect("carrot row");
19422 let mut options = state.move_destinations_for(
19423 &row.from,
19424 row.from_parent_instance_id,
19425 row.stack.item_instance_id,
19426 &row.stack.template_id,
19427 );
19428 if row.from == flatland_protocol::InventoryLocation::Root
19429 && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
19430 {
19431 options.insert(
19432 0,
19433 MoveOption {
19434 label: "Use (eat / drink)".into(),
19435 kind: MoveOptionKind::Use,
19436 },
19437 );
19438 }
19439
19440 assert_eq!(
19441 options.first().map(|o| &o.label),
19442 Some(&"Use (eat / drink)".into())
19443 );
19444 assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
19445 assert!(options
19446 .iter()
19447 .any(|o| matches!(o.kind, MoveOptionKind::Drop)));
19448 }
19449
19450 #[test]
19451 fn inventory_category_group_order_is_stable() {
19452 assert_eq!(inventory_category_group("weapon").0, "Weapons");
19453 assert_eq!(inventory_category_group("armor").0, "Armor");
19454 assert_eq!(inventory_category_group("consumable").0, "Consumables");
19455 assert_eq!(inventory_category_group("liquid").0, "Consumables");
19456 assert_eq!(inventory_category_group("resource").0, "Resources");
19457 assert_eq!(inventory_category_group("container").0, "Containers");
19458 assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
19459 assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
19460 }
19461
19462 #[test]
19463 fn page_list_index_clamps_without_wrap() {
19464 assert_eq!(page_list_index(0, -1, 25), 0);
19465 assert_eq!(page_list_index(0, 1, 25), 10);
19466 assert_eq!(page_list_index(12, 1, 25), 22);
19467 assert_eq!(page_list_index(22, 1, 25), 24);
19468 assert_eq!(page_list_index(5, 1, 0), 0);
19469 assert_eq!(page_list_index(3, -1, 8), 0);
19470 }
19471
19472 #[test]
19473 fn inventory_filter_hides_non_matching_person_items() {
19474 let mut state = sample_state();
19475 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
19476 sword.display_name = Some("Iron Sword".into());
19477 sword.category = Some("weapon".into());
19478 let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
19479 herb.display_name = Some("Wild Herb".into());
19480 herb.category = Some("consumable".into());
19481 state.inventory_stacks = vec![sword, herb];
19482 state.inventory_tab = InventoryTab::OnPerson;
19483 state.inventory_filter = "sword".into();
19484
19485 let rows = state.inventory_selectable_rows();
19486 assert_eq!(rows.len(), 1);
19487 assert_eq!(rows[0].stack.template_id, "iron_sword");
19488
19489 let lines = state.inventory_browser_lines();
19490 assert!(lines.iter().any(|l| matches!(
19491 l,
19492 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
19493 )));
19494 assert!(!lines.iter().any(|l| matches!(
19495 l,
19496 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
19497 )));
19498 }
19499
19500 #[test]
19501 fn list_filter_chars_reject_mac_arrow_glyphs() {
19502 assert!(is_list_filter_char('a'));
19503 assert!(is_list_filter_char(' '));
19504 assert!(is_list_filter_char('-'));
19505 assert!(!is_list_filter_char('\u{F700}'));
19506 assert!(!is_list_filter_char('\u{F701}'));
19507 assert!(!is_list_filter_char('\n'));
19508 }
19509
19510 #[test]
19511 fn ready_tab_keeps_in_progress_craft_after_inputs_spent() {
19512 let mut state = sample_state();
19513 state.craft_tab = CraftTab::Ready;
19514 state.blueprints = vec![BlueprintView {
19515 id: "plank".into(),
19516 label: "Plank".into(),
19517 craft_tier: 1,
19518 craft_ticks: 30,
19519 output: "wood_plank".into(),
19520 output_qty: 1,
19521 output_display_name: "Wood Plank".into(),
19522 station: None,
19523 category: None,
19524 inputs: vec![flatland_protocol::BlueprintIngredientView {
19525 template_id: "oak_log".into(),
19526 quantity: 1,
19527 consumed: true,
19528 display_name: "Oak Log".into(),
19529 }],
19530 required_tools: vec![],
19531 skill: None,
19532 failure_chance: 0.0,
19533 worker_train_copper: 0,
19534 }];
19535 state.inventory.clear();
19537 state.craft_channel_blueprint_id = Some("plank".into());
19538 state.timed_channel = Some(flatland_protocol::TimedChannelHud {
19539 label: "Crafting Plank".into(),
19540 channel: flatland_protocol::TimedChannelKind::Craft,
19541 ticks_remaining: 20,
19542 ticks_total: 30,
19543 ..Default::default()
19544 });
19545
19546 let idxs = state.craft_filtered_indices();
19547 assert_eq!(idxs, vec![0]);
19548 assert!(state.craft_blueprint_in_channel("plank"));
19549
19550 state.timed_channel = None;
19552 state.craft_channel_blueprint_id = None;
19553 assert!(state.craft_filtered_indices().is_empty());
19554 }
19555
19556 #[test]
19557 fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
19558 let mut state = sample_state();
19559 let id_a = uuid::Uuid::from_u128(0xa1);
19560 let id_b = uuid::Uuid::from_u128(0xb2);
19561 let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
19562 sword_a.display_name = Some("Iron Sword".into());
19563 sword_a.category = Some("weapon".into());
19564 sword_a.item_instance_id = Some(id_a);
19565 let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
19566 sword_b.display_name = Some("Iron Sword".into());
19567 sword_b.category = Some("weapon".into());
19568 sword_b.item_instance_id = Some(id_b);
19569 state.inventory_stacks = vec![sword_a, sword_b];
19570 state.inventory_tab = InventoryTab::OnPerson;
19571
19572 let lines = state.inventory_browser_lines();
19573 let items: Vec<_> = lines
19574 .iter()
19575 .filter_map(|l| match l {
19576 InventoryBrowserLine::Item {
19577 title,
19578 instance_tooltip,
19579 ..
19580 } => Some((title.clone(), instance_tooltip.clone())),
19581 _ => None,
19582 })
19583 .collect();
19584 assert_eq!(items.len(), 2);
19585 for (title, tip) in &items {
19586 assert!(
19587 !title.contains('#'),
19588 "title should not show instance suffix: {title}"
19589 );
19590 assert!(
19591 tip.is_some(),
19592 "two identical rows should expose instance on hover"
19593 );
19594 }
19595
19596 state.inventory_stacks.pop();
19597 let lines = state.inventory_browser_lines();
19598 let one = lines.iter().find_map(|l| match l {
19599 InventoryBrowserLine::Item {
19600 title,
19601 instance_tooltip,
19602 ..
19603 } => Some((title.clone(), instance_tooltip.clone())),
19604 _ => None,
19605 });
19606 let (title, tip) = one.expect("one sword row");
19607 assert!(!title.contains('#'));
19608 assert!(tip.is_none(), "single row should not need instance tooltip");
19609 }
19610
19611 #[test]
19612 fn inventory_person_rows_group_by_category() {
19613 let mut state = sample_state();
19614 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
19615 sword.category = Some("weapon".into());
19616 sword.display_name = Some("Iron Sword".into());
19617 let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
19618 ore.category = Some("resource".into());
19619 ore.display_name = Some("Iron Ore".into());
19620 let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
19621 potion.category = Some("consumable".into());
19622 potion.display_name = Some("Health Potion".into());
19623 state.inventory_stacks = vec![ore, potion, sword];
19624 state.inventory_tab = InventoryTab::OnPerson;
19625
19626 let lines = state.inventory_browser_lines();
19627 let labels: Vec<&str> = lines
19628 .iter()
19629 .filter_map(|l| match l {
19630 InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
19631 _ => None,
19632 })
19633 .collect();
19634 assert!(
19635 labels.iter().any(|s| s.contains("Weapons")),
19636 "expected Weapons group: {labels:?}"
19637 );
19638 assert!(labels.iter().any(|s| s.contains("Consumables")));
19639 assert!(labels.iter().any(|s| s.contains("Resources")));
19640
19641 let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
19642 let consumable_pos = labels
19643 .iter()
19644 .position(|s| s.contains("Consumables"))
19645 .unwrap();
19646 let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
19647 assert!(weapon_pos < consumable_pos);
19648 assert!(consumable_pos < resource_pos);
19649 }
19650
19651 #[test]
19652 fn inventory_tab_cycle_resets_selection() {
19653 let mut state = sample_state();
19654 state.inventory_tab = InventoryTab::OnPerson;
19655 state.inventory_menu_index = 3;
19656 state.inventory_tab = state.inventory_tab.cycle(true);
19657 assert_eq!(state.inventory_tab, InventoryTab::Nearby);
19658 assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
19660 assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
19661 assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
19662 assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
19663 }
19664
19665 #[test]
19666 fn parse_bank_copper_amount_blank_and_zero_mean_all() {
19667 assert_eq!(parse_bank_copper_amount(""), Some(0));
19668 assert_eq!(parse_bank_copper_amount(" "), Some(0));
19669 assert_eq!(parse_bank_copper_amount("0"), Some(0));
19670 assert_eq!(parse_bank_copper_amount("250"), Some(250));
19671 assert_eq!(parse_bank_copper_amount("nope"), None);
19672 }
19673
19674 #[test]
19675 fn parse_storage_quantity_blank_and_zero_mean_all() {
19676 assert_eq!(parse_storage_quantity(""), Some(None));
19677 assert_eq!(parse_storage_quantity(" "), Some(None));
19678 assert_eq!(parse_storage_quantity("0"), Some(None));
19679 assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
19680 assert_eq!(parse_storage_quantity("nope"), None);
19681 }
19682
19683 #[test]
19684 fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
19685 assert!(worker_error_is_hud_noise("path stuck — repathing"));
19686 assert!(worker_error_is_hud_noise(
19687 "path stuck — nudged clear, repathing"
19688 ));
19689 assert!(worker_error_is_hud_noise(
19690 "returned to lodging after path failures"
19691 ));
19692 assert!(!worker_error_is_hud_noise(
19694 "path stuck — no lodging to reset to"
19695 ));
19696 assert!(!worker_error_is_hud_noise(
19697 "cannot reach Eli — idling"
19698 ));
19699 assert!(worker_error_is_transient(
19700 "storage full; continuing route"
19701 ));
19702 assert!(!worker_error_is_transient(
19703 "storage full (Food Bank) — free chest space or reassign deposit"
19704 ));
19705 assert!(!worker_error_is_hud_noise(
19706 "storage full (Food Bank) — free chest space or reassign deposit"
19707 ));
19708 }
19709
19710 #[test]
19711 fn leaving_building_restores_outdoor_z_bands() {
19712 use flatland_protocol::{InteriorMapView, ZPlatformView};
19713
19714 let mut state = sample_state();
19715 state.z_platforms.clear();
19716 state.z_transitions.clear();
19717 state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
19718 state.interior_map = Some(InteriorMapView {
19719 building_id: "broker_hut".into(),
19720 blueprint_id: "broker_hut".into(),
19721 background_color: "#000".into(),
19722 default_floor_color: None,
19723 floor_height_m: 3.0,
19724 z_platforms: vec![ZPlatformView {
19725 id: "floor_0".into(),
19726 z: 0.0,
19727 x0: 0.0,
19728 y0: 0.0,
19729 x1: 8.0,
19730 y1: 8.0,
19731 }],
19732 z_transitions: vec![],
19733 rooms: vec![],
19734 room_doors: vec![],
19735 });
19736 state.sync_interior_map_context();
19737 assert_eq!(
19738 state.z_platforms.len(),
19739 1,
19740 "indoors installs interior platforms"
19741 );
19742 assert!(state.z_bands_outdoor_backup.is_some());
19743
19744 state.player.as_mut().unwrap().inside_building = None;
19745 state.sync_interior_map_context();
19746 assert!(
19747 state.z_platforms.is_empty(),
19748 "leaving must restore outdoor bands (empty), not leave interior platforms"
19749 );
19750 assert!(state.z_bands_outdoor_backup.is_none());
19751 assert!(state.interior_map.is_none());
19752 }
19753
19754 #[test]
19755 fn resource_node_route_label_prefers_friendly_label_with_suffix() {
19756 let node = ResourceNodeView {
19757 id: "crop-carrot-1_copy10".into(),
19758 label: "crop-carrot-1_copy10".into(),
19759 x: 0.0,
19760 y: 0.0,
19761 z: 0.0,
19762 item_template: "carrot".into(),
19763 state: ResourceNodeState::Available,
19764 blocking: false,
19765 blocking_radius_m: 0.5,
19766 harvest_off: false,
19767 tile_id: None,
19768 yaw: 0.0,
19769 pitch: 0.0,
19770 roll: 0.0,
19771 draw_scale: 1.0,
19772 sprite_mode: None,
19773 growth_progress: None,
19774 presentation_state: None,
19775 channel_start_tick: None,
19776 channel_end_tick: None,
19777 harvest_drop_templates: vec![],
19778 };
19779 let label = super::resource_node_route_label(&node);
19780 assert!(label.starts_with("Carrot ("), "got {label}");
19781 assert!(label.ends_with(')'), "got {label}");
19782
19783 let mut named = node;
19784 named.label = "Sweet Pad".into();
19785 named.id = "crop-carrot-a3f2b1c0".into();
19786 assert_eq!(super::resource_node_route_label(&named), "Sweet Pad (b1c0)");
19787 }
19788
19789 #[test]
19790 fn plot_public_label_uses_owner_zone_and_label() {
19791 let plot = flatland_protocol::PropertyPlotView {
19792 plot_id: uuid::Uuid::nil(),
19793 property_zone_id: "zone_a".into(),
19794 zone_label: Some("Starter Town East 1".into()),
19795 deed_instance_id: uuid::Uuid::nil(),
19796 x0: 0.0,
19797 y0: 0.0,
19798 x1: 4.0,
19799 y1: 4.0,
19800 upkeep_copper_per_day: 1,
19801 arrears_days: 0,
19802 is_mine: true,
19803 may_farm: true,
19804 purchase_basis_copper: 0,
19805 farm_public: false,
19806 public_tax_discount_bps: 0,
19807 farm_allow: vec![],
19808 owner_character_id: None,
19809 owner_label: Some("Madsin".into()),
19810 building_id: None,
19811 plot_code: "xyz1234a".into(),
19812 label: "Food Pad".into(),
19813 };
19814 assert_eq!(
19815 super::plot_public_label(&plot),
19816 "Madsin — Starter Town East 1 — Food Pad"
19817 );
19818 }
19819
19820 #[test]
19821 fn plot_public_label_uses_size_when_label_and_code_blank() {
19822 let plot = flatland_protocol::PropertyPlotView {
19823 plot_id: uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap(),
19824 property_zone_id: String::new(),
19825 zone_label: None,
19826 deed_instance_id: uuid::Uuid::nil(),
19827 x0: 10.0,
19828 y0: 20.0,
19829 x1: 18.0,
19830 y1: 28.0,
19831 upkeep_copper_per_day: 1,
19832 arrears_days: 0,
19833 is_mine: true,
19834 may_farm: true,
19835 purchase_basis_copper: 0,
19836 farm_public: false,
19837 public_tax_discount_bps: 0,
19838 farm_allow: vec![],
19839 owner_character_id: None,
19840 owner_label: None,
19841 building_id: None,
19842 plot_code: String::new(),
19843 label: String::new(),
19844 };
19845 assert_eq!(super::plot_public_label(&plot), "Homestead — Plot (8×8 m)");
19846 assert!(!super::plot_public_label(&plot).contains("19fe35f"));
19847 }
19848
19849 #[test]
19850 fn plot_stop_label_prefers_view_over_hex() {
19851 let plot_id = uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap();
19852 let plot = flatland_protocol::PropertyPlotView {
19853 plot_id,
19854 property_zone_id: "zone_a".into(),
19855 zone_label: Some("Starter Town East".into()),
19856 deed_instance_id: uuid::Uuid::nil(),
19857 x0: 0.0,
19858 y0: 0.0,
19859 x1: 4.0,
19860 y1: 4.0,
19861 upkeep_copper_per_day: 1,
19862 arrears_days: 0,
19863 is_mine: true,
19864 may_farm: true,
19865 purchase_basis_copper: 0,
19866 farm_public: false,
19867 public_tax_discount_bps: 0,
19868 farm_allow: vec![],
19869 owner_character_id: None,
19870 owner_label: Some("Madsin".into()),
19871 building_id: None,
19872 plot_code: "xyz1234a".into(),
19873 label: "Food Pad".into(),
19874 };
19875 assert_eq!(
19876 super::plot_stop_label(&[plot.clone()], plot_id),
19877 "Madsin — Starter Town East — Food Pad"
19878 );
19879 let missing = uuid::Uuid::parse_str("1a001590-0000-4000-8000-000000000002").unwrap();
19880 assert_eq!(super::plot_stop_label(&[plot], missing), "plot 1a001590");
19881 }
19882}