1use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
2use std::time::{Duration, Instant};
3
4use flatland_protocol::{
5 AbilityCooldownHud, BlueprintView, BodySlot, BuildingView, CastProgressHud, CombatCueKind,
6 CombatFxHitOutcome, CombatFxKind, CombatHud, CombatSlotHud, CombatTargetHud, DoorView,
7 EntityId, EntityState, Intent, InteriorMapView, ItemCatalogEntryView, LifeState, NpcView,
8 RotationPreset, Seq, SessionId, TerrainKindView, TerrainZoneView, Tick, ZPlatformView,
9 ZTransitionView,
10};
11
12use crate::session::{PlayConnection, SessionEvent};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum CharacterSheetTab {
16 #[default]
17 Character,
18 Ledger,
19 Career,
20}
21
22impl CharacterSheetTab {
23 pub fn cycle(self) -> Self {
24 match self {
25 Self::Character => Self::Ledger,
26 Self::Ledger => Self::Career,
27 Self::Career => Self::Character,
28 }
29 }
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum LedgerPeriod {
34 #[default]
35 Day,
36 Week,
37 Month,
38 Lifetime,
39}
40
41impl LedgerPeriod {
42 pub fn label(self) -> &'static str {
43 match self {
44 Self::Day => "Day",
45 Self::Week => "Week",
46 Self::Month => "Month",
47 Self::Lifetime => "All",
48 }
49 }
50
51 pub fn cycle(self) -> Self {
52 match self {
53 Self::Day => Self::Week,
54 Self::Week => Self::Month,
55 Self::Month => Self::Lifetime,
56 Self::Lifetime => Self::Day,
57 }
58 }
59
60 pub fn from_digit(c: char) -> Option<Self> {
61 match c {
62 '1' => Some(Self::Day),
63 '2' => Some(Self::Week),
64 '3' => Some(Self::Month),
65 '4' => Some(Self::Lifetime),
66 _ => None,
67 }
68 }
69}
70
71const KEY_TEMPLATE: &str = "container_key";
73const PROPERTY_DEED_TEMPLATE: &str = "property_deed";
74const PROP_LOCK_ID: &str = "lock_id";
75const PROP_OPENS_LOCK_ID: &str = "opens_lock_id";
76const PROP_OPENS_CONTAINER_NAME: &str = "opens_container_name";
77const PROP_CUSTOM_NAME: &str = "custom_name";
78const PROP_LOCKED: &str = "locked";
79
80#[derive(Debug, Clone, PartialEq)]
82pub struct ClaimModeState {
83 pub zone_id: String,
84 pub width_m: u32,
85 pub height_m: u32,
86 pub anchor_x: f32,
87 pub anchor_y: f32,
88}
89
90#[derive(Debug, Clone, PartialEq)]
92pub struct RelocateModeState {
93 pub container_id: String,
94 pub label: String,
95 pub cursor_x: f32,
96 pub cursor_y: f32,
97}
98
99fn stack_is_locked(stack: &flatland_protocol::ItemStack) -> bool {
100 stack
101 .props
102 .get(PROP_LOCKED)
103 .is_some_and(|v| v == "true" || v == "1")
104}
105
106const MAX_LOG_LINES: usize = 200;
107const MAX_SHOP_TRADE_LOG_LINES: usize = 40;
108const INTERACTION_RADIUS_M: f32 = 1.5;
109const DOOR_INTERACTION_RADIUS_M: f32 = 2.0;
110const QUEST_BOARD_INTERACTION_RADIUS_M: f32 = 3.0;
111const HARVEST_CLIENT_TIMEOUT: Duration = Duration::from_secs(12);
112const CRAFT_STAMINA_COST: f32 = 3.0;
114const CRAFT_BATCH_SELECT_CAP: u32 = 999;
116const WORKER_STEP_HOLD: Duration = Duration::from_millis(1200);
118const WORKER_ERROR_HOLD: Duration = Duration::from_secs(45);
120const WORKER_HEALTH_RING_HOLD: Duration = Duration::from_secs(6);
122const WORKER_HIRE_PENDING_TIMEOUT: Duration = Duration::from_secs(15);
124
125#[derive(Debug, Clone, Default)]
127pub struct InventoryHint {
128 pub display_name: String,
129 pub category: String,
130 pub base_mass: Option<f32>,
131 pub base_volume: Option<f32>,
132 pub capacity_volume: Option<f32>,
133 pub stackable: bool,
134 pub listable: bool,
136 pub base_value_copper: Option<u32>,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct LoadoutHotbarChoice {
143 pub binding: String,
145 pub label: String,
147 pub meta: Option<String>,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
153pub enum RotationEditorMode {
154 #[default]
155 List,
156 EditSequence,
157 PickAbility,
158 EditLabel,
159}
160
161#[derive(Debug, Clone, Default)]
163pub struct RotationEditorState {
164 pub mode: RotationEditorMode,
165 pub list_index: usize,
166 pub ability_index: usize,
167 pub picker_index: usize,
168 pub draft: Option<RotationPreset>,
169 pub label_buffer: String,
170}
171
172impl RotationEditorState {
173 pub fn reset(&mut self) {
174 *self = Self::default();
175 }
176}
177
178pub const CONTAINER_RANGE_M: f32 = 3.0;
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum InventorySection {
188 Worn,
190 Person,
192 Nearby,
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
198pub enum InventoryTab {
199 #[default]
200 OnPerson,
201 Nearby,
202}
203
204impl InventoryTab {
205 pub fn label(self) -> &'static str {
206 match self {
207 Self::OnPerson => "On person",
208 Self::Nearby => "Nearby storage",
209 }
210 }
211
212 pub fn cycle(self, forward: bool) -> Self {
213 match (self, forward) {
214 (Self::OnPerson, true) | (Self::OnPerson, false) => Self::Nearby,
215 (Self::Nearby, true) | (Self::Nearby, false) => Self::OnPerson,
216 }
217 }
218}
219
220pub const LIST_PAGE_SIZE: usize = 10;
222
223pub fn list_label_matches(haystack: &str, filter: &str) -> bool {
225 if filter.is_empty() {
226 return true;
227 }
228 haystack
229 .to_ascii_lowercase()
230 .contains(&filter.to_ascii_lowercase())
231}
232
233pub fn is_list_filter_char(ch: char) -> bool {
236 match ch {
237 ' '..='~' => true,
238 c if c.is_alphanumeric() => true,
239 _ => false,
240 }
241}
242
243pub fn page_list_index(index: usize, pages: i32, len: usize) -> usize {
245 if len == 0 {
246 return 0;
247 }
248 let page = LIST_PAGE_SIZE as i32;
249 let next = index as i32 + pages * page;
250 next.clamp(0, (len as i32) - 1) as usize
251}
252
253pub fn step_filtered_index(
255 index: usize,
256 delta: i32,
257 len: usize,
258 pred: impl Fn(usize) -> bool,
259) -> usize {
260 if len == 0 {
261 return 0;
262 }
263 let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
264 if matching.is_empty() {
265 return index.min(len - 1);
266 }
267 let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
268 let next = (pos as i32 + delta).rem_euclid(matching.len() as i32) as usize;
269 matching[next]
270}
271
272pub fn page_filtered_index(
274 index: usize,
275 pages: i32,
276 len: usize,
277 pred: impl Fn(usize) -> bool,
278) -> usize {
279 if len == 0 {
280 return 0;
281 }
282 let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
283 if matching.is_empty() {
284 return index.min(len - 1);
285 }
286 let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
287 let next = page_list_index(pos, pages, matching.len());
288 matching[next]
289}
290
291pub fn inventory_category_group(category: &str) -> (&'static str, u8) {
293 match category {
294 "weapon" | "ammo" => ("Weapons", 0),
295 "armor" | "shield" | "offhand" => ("Armor", 1),
296 "consumable" | "liquid" | "bulk" => ("Consumables", 2),
297 "resource" | "harvest_node" | "seed" => ("Resources", 3),
298 "container" | "lodging" => ("Containers", 4),
299 "currency" | "key" => ("Currency & keys", 5),
300 "tool" | "misc" | "furniture" | "quest" | "document" => ("Gear & misc", 6),
301 _ => ("Other", 7),
302 }
303}
304
305pub fn category_default_listable(category: &str) -> bool {
307 !matches!(
308 category,
309 "currency" | "harvest_node" | "key" | "quest" | "document" | "lodging"
310 )
311}
312
313fn vessel_holds_category(stack: &flatland_protocol::ItemStack, category: Option<&str>) -> bool {
314 let cat = category.unwrap_or("");
315 if let Some(holds) = stack.props.get("serving_holds") {
316 return holds.split(',').any(|p| {
317 let p = p.trim();
318 p == cat
319 || (cat == "liquid" && p == "liquid")
320 || (cat == "bulk" && p == "bulk")
321 || (matches!(cat, "consumable") && p == "food")
322 });
323 }
324 match cat {
326 "bulk" => stack.props.get("bulk_vessel").is_some_and(|v| v == "1"),
327 "liquid" => stack.props.get("liquid_vessel").is_some_and(|v| v == "1"),
328 _ => false,
329 }
330}
331
332fn serving_capacity_of(stack: &flatland_protocol::ItemStack) -> u32 {
333 stack
334 .props
335 .get("serving_capacity")
336 .and_then(|s| s.parse().ok())
337 .unwrap_or(0)
338}
339
340fn payload_units_in_vessel(stack: &flatland_protocol::ItemStack) -> u32 {
341 stack.contents.iter().map(|c| c.quantity).sum()
342}
343
344fn is_serving_vessel_stack(stack: &flatland_protocol::ItemStack) -> bool {
345 stack.props.get("serving").is_some_and(|v| v == "1")
346 || stack.props.get("liquid_vessel").is_some_and(|v| v == "1")
347 || stack.props.get("bulk_vessel").is_some_and(|v| v == "1")
348 || stack.props.contains_key("serving_holds")
349 || stack.props.contains_key("serving_capacity")
350}
351
352fn vessel_free_room_for_payload(
353 stack: &flatland_protocol::ItemStack,
354 payload_id: &str,
355 payload_category: Option<&str>,
356) -> u32 {
357 if !is_serving_vessel_stack(stack) || !vessel_holds_category(stack, payload_category) {
358 return 0;
359 }
360 let primary = stack.contents.iter().find(|c| c.quantity > 0);
361 let compatible = primary.is_none_or(|c| c.template_id == payload_id);
362 if !compatible {
363 return 0;
364 }
365 let cap = serving_capacity_of(stack);
366 let used = payload_units_in_vessel(stack);
367 let per_shell = cap.saturating_sub(used);
368 if per_shell == 0 {
369 return 0;
370 }
371 let shells = if stack.contents.is_empty() {
373 stack.quantity.max(1)
374 } else {
375 1
376 };
377 per_shell.saturating_mul(shells)
378}
379
380fn drain_payload_from_stacks(
381 stacks: &mut [flatland_protocol::ItemStack],
382 template_id: &str,
383 remaining: &mut u32,
384) {
385 if *remaining == 0 {
386 return;
387 }
388 for stack in stacks.iter_mut() {
389 if *remaining == 0 {
390 return;
391 }
392 if stack.template_id == template_id && stack.quantity > 0 {
393 let take = (*remaining).min(stack.quantity);
394 stack.quantity -= take;
395 *remaining -= take;
396 }
397 drain_payload_from_stacks(&mut stack.contents, template_id, remaining);
398 stack.contents.retain(|c| c.quantity > 0);
400 }
401}
402
403fn vessel_room_for_payload_in_stacks(
404 stacks: &[flatland_protocol::ItemStack],
405 payload_id: &str,
406 payload_category: Option<&str>,
407) -> u32 {
408 let mut room = 0u32;
409 for stack in stacks {
410 room = room.saturating_add(vessel_free_room_for_payload(
411 stack,
412 payload_id,
413 payload_category,
414 ));
415 room = room.saturating_add(vessel_room_for_payload_in_stacks(
416 &stack.contents,
417 payload_id,
418 payload_category,
419 ));
420 }
421 room
422}
423
424#[derive(Debug, Clone)]
426pub struct CraftVesselLine {
427 pub label: String,
428 pub holds: String,
429 pub capacity: u32,
430 pub used: u32,
431 pub free: u32,
432 pub quantity: u32,
433 pub accepts_output: bool,
434 pub location: &'static str,
435}
436
437#[derive(Debug, Clone)]
439pub struct CraftVesselStatus {
440 pub needs_vessel: bool,
441 pub output_label: String,
442 pub need_units: u32,
443 pub free_after_inputs: u32,
444 pub ok: bool,
445 pub vessels: Vec<CraftVesselLine>,
446}
447
448#[derive(Debug, Clone, Copy, PartialEq, Eq)]
450pub enum CraftTab {
451 Ready,
452 Favorites,
453 Recent,
454 Tier(u32),
455}
456
457impl CraftTab {
458 pub fn label(self) -> String {
459 match self {
460 Self::Ready => "Ready".into(),
461 Self::Favorites => "★".into(),
462 Self::Recent => "Recent".into(),
463 Self::Tier(n) => format!("T{n}"),
464 }
465 }
466}
467
468pub fn npc_market_dump_unit_estimate_copper(base_value: u32) -> Option<u32> {
470 if base_value == 0 {
471 return None;
472 }
473 let unit = ((base_value as f32) * 0.5).floor() as u32;
474 if unit == 0 {
475 return None;
476 }
477 Some(((unit as u64).saturating_mul(9500) / 10_000).max(1) as u32)
478}
479
480fn parse_bank_copper_amount(input: &str) -> Option<u64> {
482 let s = input.trim();
483 if s.is_empty() {
484 return Some(0);
485 }
486 s.parse::<u64>().ok()
487}
488
489fn parse_storage_quantity(input: &str) -> Option<Option<u32>> {
491 let s = input.trim();
492 if s.is_empty() || s == "0" {
493 return Some(None);
494 }
495 let n = s.parse::<u32>().ok()?;
496 if n == 0 {
497 return Some(None);
498 }
499 Some(Some(n))
500}
501
502fn storage_stack_label(stack: &flatland_protocol::ItemStack) -> String {
503 let name = stack
504 .display_name
505 .as_deref()
506 .unwrap_or(stack.template_id.as_str());
507 if stack.quantity > 1 {
508 format!("{name} ×{}", stack.quantity)
509 } else {
510 name.to_string()
511 }
512}
513
514pub fn body_slot_label(slot: BodySlot) -> &'static str {
517 match slot {
518 BodySlot::Head => "Head",
519 BodySlot::Chest => "Chest",
520 BodySlot::Forearms => "Forearms",
521 BodySlot::Legs => "Legs",
522 BodySlot::Feet => "Feet",
523 BodySlot::Cloak => "Cloak",
524 BodySlot::Back => "Back",
525 BodySlot::Waist => "Waist",
526 BodySlot::Earrings => "Earrings",
527 BodySlot::Necklace => "Necklace",
528 BodySlot::Eyeglasses => "Eyeglasses",
529 BodySlot::RingLeft1 => "Ring L1",
530 BodySlot::RingLeft2 => "Ring L2",
531 BodySlot::RingRight1 => "Ring R1",
532 BodySlot::RingRight2 => "Ring R2",
533 }
534}
535
536fn grant_target_matches_mode(stack: &flatland_protocol::ItemStack, mode: &str) -> bool {
537 let cat = stack.category.as_deref().unwrap_or("");
538 match mode {
539 "while_equipped" => {
540 stack.equip_slot.is_some()
541 || cat == "weapon"
542 || cat == "shield"
543 || cat == "offhand"
544 || cat == "armor"
545 }
546 _ => cat == "weapon" || cat == "ammo" || stack.props.contains_key("weapon_ability_id"),
547 }
548}
549
550fn grant_tags_match(stack: &flatland_protocol::ItemStack, grant_tags: &[&str]) -> bool {
551 if grant_tags.is_empty() {
552 return true;
553 }
554 let target_tags: Vec<&str> = stack
555 .props
556 .get("allowed_enchant_tags")
557 .map(|s| {
558 s.split(',')
559 .map(str::trim)
560 .filter(|t| !t.is_empty())
561 .collect()
562 })
563 .unwrap_or_default();
564 if target_tags.is_empty() {
565 return true;
566 }
567 grant_tags.iter().any(|t| target_tags.contains(t))
568}
569
570pub const DEFAULT_TICK_HZ: u32 = 30;
572
573pub fn format_binding_ttl(
575 binding: &flatland_protocol::ItemStatusBinding,
576 tick: u64,
577 tick_hz: u32,
578) -> String {
579 let Some(expires) = binding.expires_at_tick else {
580 return "permanent".into();
581 };
582 let hz = tick_hz.max(1) as f32;
583 let remaining = expires.saturating_sub(tick) as f32 / hz;
584 if remaining <= 0.0 {
585 return "expired".into();
586 }
587 if remaining >= 120.0 {
588 format!("{:.0}m left", remaining / 60.0)
589 } else if remaining >= 10.0 {
590 format!("{remaining:.0}s left")
591 } else {
592 format!("{remaining:.1}s left")
593 }
594}
595
596pub fn format_binding_mode(mode: flatland_protocol::ItemStatusBindingMode) -> &'static str {
597 match mode {
598 flatland_protocol::ItemStatusBindingMode::OnHit => "on hit",
599 flatland_protocol::ItemStatusBindingMode::WhileEquipped => "while equipped",
600 }
601}
602
603pub fn format_status_bindings_suffix(
605 bindings: &[flatland_protocol::ItemStatusBinding],
606 tick: u64,
607 tick_hz: u32,
608) -> String {
609 if bindings.is_empty() {
610 return String::new();
611 }
612 let parts: Vec<String> = bindings
613 .iter()
614 .map(|b| {
615 format!(
616 "{} ({}, {})",
617 b.effect_id,
618 format_binding_mode(b.mode),
619 format_binding_ttl(b, tick, tick_hz)
620 )
621 })
622 .collect();
623 format!(" · {}", parts.join("; "))
624}
625
626#[derive(Debug, Clone, Copy, PartialEq, Eq)]
627pub enum EquipPaperdollRow {
628 Body { slot: BodySlot, filled: bool },
629 Mainhand { filled: bool },
630 Offhand { filled: bool, locked: bool },
631}
632
633pub fn equip_paperdoll_rows(state: &GameState) -> Vec<EquipPaperdollRow> {
634 let mut rows: Vec<EquipPaperdollRow> = BodySlot::ALL
635 .iter()
636 .map(|slot| EquipPaperdollRow::Body {
637 slot: *slot,
638 filled: state.worn.contains_key(slot),
639 })
640 .collect();
641 let two_hand = state.mainhand_hand_slots >= 2;
642 rows.push(EquipPaperdollRow::Mainhand {
643 filled: state.mainhand_template_id.is_some(),
644 });
645 rows.push(EquipPaperdollRow::Offhand {
646 filled: state.offhand_template_id.is_some(),
647 locked: two_hand,
648 });
649 rows
650}
651
652fn first_inventory_for_slot(state: &GameState, slot: BodySlot) -> Option<uuid::Uuid> {
653 for stack in &state.inventory_stacks {
654 let matches = stack
655 .equip_slot
656 .map(|s| s == slot || (is_client_ring(s) && is_client_ring(slot)))
657 .unwrap_or(false)
658 || guess_body_slot(&stack.template_id) == Some(slot);
659 if matches {
660 return stack.item_instance_id;
661 }
662 }
663 None
664}
665
666fn is_client_ring(slot: BodySlot) -> bool {
667 matches!(
668 slot,
669 BodySlot::RingLeft1 | BodySlot::RingLeft2 | BodySlot::RingRight1 | BodySlot::RingRight2
670 )
671}
672
673fn first_inventory_weapon(state: &GameState) -> Option<String> {
674 for stack in &state.inventory_stacks {
675 if stack.category.as_deref() == Some("weapon") {
676 return Some(stack.template_id.clone());
677 }
678 }
679 None
680}
681
682fn first_inventory_offhand(state: &GameState) -> Option<String> {
683 for stack in &state.inventory_stacks {
684 let cat = stack.category.as_deref().unwrap_or("");
685 if matches!(cat, "shield" | "offhand") {
686 return Some(stack.template_id.clone());
687 }
688 }
689 None
690}
691
692fn guess_body_slot(template_id: &str) -> Option<BodySlot> {
695 if template_id.contains("backpack") {
696 Some(BodySlot::Back)
697 } else if template_id.contains("belt") {
698 Some(BodySlot::Waist)
699 } else if template_id.contains("cloak") || template_id.contains("cape") {
700 Some(BodySlot::Cloak)
701 } else if template_id.contains("cap")
702 || template_id.contains("hat")
703 || template_id.contains("helm")
704 {
705 Some(BodySlot::Head)
706 } else if template_id.contains("shirt")
707 || template_id.contains("robe")
708 || template_id.contains("vest")
709 || template_id.contains("chest")
710 || template_id.contains("jerkin")
711 {
712 Some(BodySlot::Chest)
713 } else if template_id.contains("sleeves")
714 || template_id.contains("gloves")
715 || template_id.contains("gauntlets")
716 {
717 Some(BodySlot::Forearms)
718 } else if template_id.contains("pants") || template_id.contains("leggings") {
719 Some(BodySlot::Legs)
720 } else if template_id.contains("boots") || template_id.contains("shoes") {
721 Some(BodySlot::Feet)
722 } else if template_id.contains("earring") {
723 Some(BodySlot::Earrings)
724 } else if template_id.contains("necklace") || template_id.contains("amulet") {
725 Some(BodySlot::Necklace)
726 } else if template_id.contains("glass")
727 || template_id.contains("spectacles")
728 || template_id.contains("goggles")
729 {
730 Some(BodySlot::Eyeglasses)
731 } else if template_id.contains("ring") {
732 Some(BodySlot::RingLeft1)
733 } else {
734 None
735 }
736}
737
738#[derive(Debug, Clone)]
740pub struct InventoryRow {
741 pub depth: usize,
742 pub stack: flatland_protocol::ItemStack,
743 pub from: flatland_protocol::InventoryLocation,
745 pub from_parent_instance_id: Option<uuid::Uuid>,
747 pub is_equip_shell: bool,
749 pub is_chest_shell: bool,
751 pub section: InventorySection,
752}
753
754#[derive(Debug, Clone)]
756pub struct InventoryRowView {
757 pub depth: usize,
758 pub text: String,
760 pub title: String,
762 pub mass_kg: Option<f32>,
763 pub volume: Option<(f32, f32)>,
764 pub instance_tooltip: Option<String>,
766}
767
768#[derive(Debug, Clone)]
770pub enum InventoryBrowserLine {
771 Section(String),
772 SlotLabel(String),
773 Hint(String),
774 Blank,
775 Item {
776 selectable_index: usize,
777 selected: bool,
778 depth: usize,
779 text: String,
780 title: String,
781 mass_kg: Option<f32>,
782 volume: Option<(f32, f32)>,
783 instance_tooltip: Option<String>,
784 },
785}
786
787#[derive(Debug, Clone, PartialEq, Eq, Default)]
789pub enum BankUiMode {
790 #[default]
791 Menu,
792 DepositAmount {
793 input: String,
794 },
795 WithdrawAmount {
796 input: String,
797 },
798 TransferName {
799 input: String,
800 },
801 TransferAmount {
802 to_name: String,
803 input: String,
804 },
805}
806
807#[derive(Debug, Clone, PartialEq, Eq, Default)]
809pub enum StorageUiMode {
810 #[default]
811 Menu,
812 StorePick { index: usize },
814 StoreAmount {
816 pick_index: usize,
817 item_instance_id: uuid::Uuid,
818 label: String,
819 max_qty: u32,
820 input: String,
821 },
822 TakePick { index: usize },
824 TakeAmount {
826 pick_index: usize,
827 item_instance_id: uuid::Uuid,
828 label: String,
829 max_qty: u32,
830 input: String,
831 },
832 ShipPick {
834 dest_building_id: String,
835 dest_label: String,
836 index: usize,
837 },
838 ShipAmount {
840 dest_building_id: String,
841 dest_label: String,
842 pick_index: usize,
843 item_instance_id: uuid::Uuid,
844 label: String,
845 max_qty: u32,
846 input: String,
847 },
848}
849
850#[derive(Debug, Clone, PartialEq, Eq)]
852pub enum MarketListSourceKind {
853 Person,
854 TownStorage { building_id: String },
855}
856
857#[derive(Debug, Clone, PartialEq, Eq, Default)]
859pub enum MarketUiMode {
860 #[default]
861 Browse,
862 ListSource { index: usize },
864 ListPick {
866 source: MarketListSourceKind,
867 index: usize,
868 },
869 ListAmount {
871 source: MarketListSourceKind,
872 pick_index: usize,
873 item_instance_id: uuid::Uuid,
874 template_id: String,
875 label: String,
876 max_qty: u32,
877 input: String,
878 },
879 ListPricingMode {
881 source: MarketListSourceKind,
882 pick_index: usize,
883 item_instance_id: uuid::Uuid,
884 template_id: String,
885 label: String,
886 quantity: Option<u32>,
887 max_qty: u32,
888 index: usize,
890 },
891 ListPrice {
893 source: MarketListSourceKind,
894 pick_index: usize,
895 item_instance_id: uuid::Uuid,
896 template_id: String,
897 label: String,
898 quantity: Option<u32>,
900 max_qty: u32,
901 input: String,
902 },
903}
904
905#[derive(Debug, Clone)]
907pub struct StoragePickOption {
908 pub item_instance_id: uuid::Uuid,
909 pub template_id: String,
910 pub label: String,
911 pub quantity: u32,
912 pub category: String,
914}
915
916#[derive(Debug, Clone)]
919pub struct NearbyContainer {
920 pub view: flatland_protocol::PlacedContainerView,
921 pub distance_m: f32,
922 pub rows: Vec<InventoryRow>,
923}
924
925#[derive(Debug, Clone)]
927pub struct KeychainEntry {
928 pub stack: flatland_protocol::ItemStack,
929 pub stowed: bool,
930}
931
932#[derive(Debug, Clone)]
934pub struct MoveOption {
935 pub label: String,
936 pub kind: MoveOptionKind,
937 pub volume: Option<(f32, f32)>,
939}
940
941impl MoveOption {
942 fn action(label: impl Into<String>, kind: MoveOptionKind) -> Self {
943 Self {
944 label: label.into(),
945 kind,
946 volume: None,
947 }
948 }
949
950 pub fn volume_usage_label(&self) -> Option<String> {
952 self.volume
953 .map(|(used, cap)| format_container_volume_usage(used, cap))
954 }
955}
956
957pub fn format_container_volume_usage(used: f32, cap: f32) -> String {
959 let used = used.max(0.0);
960 let cap = cap.max(0.0);
961 let free = (cap - used).max(0.0);
962 format!("vol {used:.0}/{cap:.0} ({free:.0} free)")
963}
964
965#[derive(Debug, Clone, PartialEq)]
966pub enum MoveOptionKind {
967 Move {
968 location: flatland_protocol::InventoryLocation,
969 parent_instance_id: Option<uuid::Uuid>,
970 },
971 PickupPlaced {
973 container_id: String,
974 nest_location: flatland_protocol::InventoryLocation,
975 nest_parent_instance_id: Option<uuid::Uuid>,
976 },
977 RelocatePlaced {
979 container_id: String,
980 },
981 Use,
983 GrantApply,
985 Drop,
986 SellPlotToCrown {
988 plot_id: uuid::Uuid,
989 },
990 Cancel,
991}
992
993#[derive(Debug, Clone, PartialEq)]
995pub enum FarmAccessRow {
996 PublicToggle,
997 PublicDiscount,
998 AllowRemove {
999 character_id: uuid::Uuid,
1000 label: String,
1001 tax_discount_bps: u32,
1002 },
1003 NearbyAdd {
1004 name: String,
1005 },
1006}
1007
1008#[derive(Debug, Clone)]
1010pub struct GrantTargetPicker {
1011 pub grant_instance_id: uuid::Uuid,
1012 pub grant_label: String,
1013 pub effect_id: String,
1014 pub mode: String,
1015 pub options: Vec<GrantTargetOption>,
1016 pub filter: String,
1017 pub filter_focused: bool,
1018}
1019
1020#[derive(Debug, Clone)]
1021pub struct GrantTargetOption {
1022 pub label: String,
1023 pub target_instance_id: uuid::Uuid,
1024}
1025
1026#[derive(Debug, Clone)]
1028pub struct MovePicker {
1029 pub item_instance_id: uuid::Uuid,
1030 pub from: flatland_protocol::InventoryLocation,
1031 pub item_label: String,
1032 pub template_id: String,
1033 pub stack_quantity: u32,
1034 pub quantity: u32,
1035 pub options: Vec<MoveOption>,
1036 pub filter: String,
1037 pub filter_focused: bool,
1038}
1039
1040#[derive(Debug, Clone)]
1042pub struct DestroyPicker {
1043 pub item_instance_id: uuid::Uuid,
1044 pub from: flatland_protocol::InventoryLocation,
1045 pub item_label: String,
1046 pub stack_quantity: u32,
1047 pub quantity: u32,
1048}
1049
1050#[derive(Debug, Clone)]
1052pub struct WorkerGiveOption {
1053 pub item_instance_id: uuid::Uuid,
1054 pub label: String,
1055 pub quantity: u32,
1056 pub template_id: String,
1057}
1058
1059#[derive(Debug, Clone)]
1061pub struct WorkerGivePicker {
1062 pub worker_instance_id: String,
1063 pub worker_label: String,
1064 pub options: Vec<WorkerGiveOption>,
1065}
1066
1067#[derive(Debug, Clone)]
1069pub struct WorkerGiveTargetOption {
1070 pub instance_id: String,
1071 pub label: String,
1072 pub distance_m: f32,
1073}
1074
1075#[derive(Debug, Clone)]
1077pub struct WorkerGiveTargetPicker {
1078 pub item_instance_id: uuid::Uuid,
1079 pub item_label: String,
1080 pub quantity: Option<u32>,
1081 pub options: Vec<WorkerGiveTargetOption>,
1082}
1083
1084#[derive(Debug, Clone)]
1086pub struct WorkerTakePicker {
1087 pub worker_instance_id: String,
1088 pub worker_label: String,
1089 pub options: Vec<WorkerGiveOption>,
1090 pub quantity: u32,
1092}
1093
1094pub const WORKER_GIVE_RANGE_M: f32 = 4.0;
1096
1097#[derive(Debug, Clone)]
1099pub struct WorkerTeachOption {
1100 pub blueprint_id: String,
1101 pub label: String,
1102 pub cost_copper: u64,
1103 pub min_level: u32,
1104 pub worker_level: u32,
1105 pub can_afford: bool,
1106 pub level_ok: bool,
1107}
1108
1109#[derive(Debug, Clone)]
1111pub struct WorkerTeachPicker {
1112 pub worker_instance_id: String,
1113 pub worker_label: String,
1114 pub worker_level: u32,
1115 pub options: Vec<WorkerTeachOption>,
1116}
1117
1118#[derive(Debug, Clone)]
1120pub struct WorkerDismissConfirmation {
1121 pub worker_instance_id: String,
1122 pub worker_label: String,
1123}
1124
1125#[derive(Debug, Clone, Default)]
1128pub struct StickyWorkerStep {
1129 shown: String,
1130 pending: String,
1131 pending_since: Option<Instant>,
1132}
1133
1134impl StickyWorkerStep {
1135 fn from_label(label: String) -> Self {
1136 Self {
1137 shown: label.clone(),
1138 pending: label,
1139 pending_since: Some(Instant::now()),
1140 }
1141 }
1142
1143 fn observe(&mut self, label: &str, now: Instant) {
1144 let pending_since = self.pending_since.unwrap_or(now);
1145 if label == self.pending {
1146 if self.shown != self.pending && now.duration_since(pending_since) >= WORKER_STEP_HOLD {
1147 self.shown = self.pending.clone();
1148 }
1149 return;
1150 }
1151 self.pending = label.to_string();
1152 self.pending_since = Some(now);
1153 if self.shown.is_empty() {
1155 self.shown = self.pending.clone();
1156 }
1157 }
1158}
1159
1160#[derive(Debug, Clone, Default)]
1163pub struct StickyWorkerError {
1164 message: String,
1165 last_seen: Option<Instant>,
1166}
1167
1168impl StickyWorkerError {
1169 fn observe(&mut self, err: Option<&str>, now: Instant) {
1170 if let Some(e) = err {
1171 if !worker_error_is_transient(e) && !worker_error_is_hud_noise(e) {
1172 self.message = e.to_string();
1173 self.last_seen = Some(now);
1174 }
1175 return;
1176 }
1177 if let Some(seen) = self.last_seen {
1178 if now.duration_since(seen) > WORKER_ERROR_HOLD {
1179 self.message.clear();
1180 self.last_seen = None;
1181 }
1182 }
1183 }
1184
1185 pub fn shown(&self, now: Instant) -> Option<&str> {
1186 if self.message.is_empty() {
1187 return None;
1188 }
1189 let seen = self.last_seen?;
1190 if now.duration_since(seen) > WORKER_ERROR_HOLD {
1191 return None;
1192 }
1193 Some(self.message.as_str())
1194 }
1195}
1196
1197pub fn worker_attention_line(state: &GameState) -> Option<String> {
1200 use flatland_protocol::WorkerStateView;
1201 let now = Instant::now();
1202 for w in &state.hired_workers {
1203 if matches!(w.state, WorkerStateView::Strike) {
1204 return Some(format!(
1205 "Worker {}: on strike — fund bank, pay wages, or stock lodging chest",
1206 w.label
1207 ));
1208 }
1209 let sticky = state
1210 .worker_error_display
1211 .get(&w.instance_id)
1212 .and_then(|s| s.shown(now))
1213 .filter(|e| !worker_error_is_hud_noise(e));
1214 let live = w
1215 .last_error
1216 .as_deref()
1217 .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e));
1218 if let Some(err) = sticky.or(live) {
1219 if let Some(hint) = w
1220 .issue_hint
1221 .as_deref()
1222 .filter(|h| !h.is_empty())
1223 .or_else(|| worker_issue_fix_hint(err))
1224 {
1225 return Some(format!("Worker {}: {err} — {hint}", w.label));
1226 }
1227 return Some(format!("Worker {}: {err}", w.label));
1228 }
1229 if let Some(hint) = w.issue_hint.as_deref().filter(|h| !h.is_empty()) {
1231 return Some(format!("Worker {}: {hint}", w.label));
1232 }
1233 }
1234 None
1235}
1236
1237pub fn worker_issue_fix_hint(err: &str) -> Option<&'static str> {
1239 let e = err.to_ascii_lowercase();
1240 if e.contains("missing")
1241 || e.contains("container not found")
1242 || e.contains("lodging container not found")
1243 {
1244 return Some("edit route (e): replace the missing chest/bed");
1245 }
1246 if e.contains("stranded at interior") || e.contains("interior map coords") {
1247 return Some("recovered — continuing route");
1248 }
1249 if e.contains("stuck inside")
1250 || e.contains("sent outside")
1251 || e.contains("sent to door")
1252 || e.contains("left building")
1253 {
1254 return Some("auto-exit for outdoor work — restart after update if it still loops");
1255 }
1256 if e.contains("collapsed") || e.contains("need food") {
1257 return Some("stock lodging bed with food and drink");
1258 }
1259 if e.contains("overburdened") {
1260 return Some("add a deposit/sell stop, or empty their pack");
1261 }
1262 if e.contains("storage full") {
1263 return Some("empty or upgrade the destination chest, or reassign the deposit");
1264 }
1265 if e.contains("need a hoe") || e.contains("need a dibber") {
1266 return Some("give them the tool or withdraw it on the route");
1267 }
1268 if e.contains("idling at lodging") && e.contains("no path") {
1269 return Some("clear blockers or edit the harvest route");
1270 }
1271 None
1272}
1273
1274pub fn worker_error_is_transient(err: &str) -> bool {
1276 let e = err.to_ascii_lowercase();
1277 e.contains("trying next node")
1278 || e.contains("continuing route")
1279 || e.starts_with("nothing to withdraw")
1280}
1281
1282pub fn worker_error_is_hud_noise(err: &str) -> bool {
1285 let e = err.to_ascii_lowercase();
1286 if e.contains("idling")
1288 && (e.contains("cannot reach")
1289 || e.contains("unreachable")
1290 || e.contains("idling at lodging"))
1291 {
1292 return false;
1293 }
1294 e.contains("returned to lodging after path")
1295 || e.contains("path failure")
1296 || (e.contains("no path to") && !e.contains("idling"))
1297 || e.contains("pathfinding")
1298 || e.contains("repathing")
1300 || e.contains("nudged clear")
1301 || e.contains("path unreachable (plan failures 0, leg 0)")
1303 || e.contains("auto-recovery")
1305 || e.contains("stranded at interior map coords")
1306}
1307
1308#[derive(Debug, Clone)]
1310pub struct PendingWorkerJobAck {
1311 pub seq: u32,
1312 pub worker_instance_id: String,
1313 pub worker_label: String,
1314 pub idle: bool,
1315 pub stop_count: usize,
1316 pub prev_route: Option<flatland_protocol::WorkerRouteView>,
1317 pub prev_mode: flatland_protocol::WorkerModeView,
1318 pub prev_step_label: String,
1319 pub prev_last_error: Option<String>,
1320}
1321
1322fn push_inventory_rows(
1323 rows: &mut Vec<InventoryRow>,
1324 depth: usize,
1325 stack: &flatland_protocol::ItemStack,
1326 from: &flatland_protocol::InventoryLocation,
1327 from_parent_instance_id: Option<uuid::Uuid>,
1328 section: InventorySection,
1329) {
1330 push_inventory_rows_filtered(
1331 rows,
1332 depth,
1333 stack,
1334 from,
1335 from_parent_instance_id,
1336 section,
1337 "",
1338 );
1339}
1340
1341fn stack_matches_filter(stack: &flatland_protocol::ItemStack, filter: &str) -> bool {
1342 if filter.is_empty() {
1343 return true;
1344 }
1345 let f = filter.to_ascii_lowercase();
1346 let name = stack
1347 .display_name
1348 .as_deref()
1349 .unwrap_or("")
1350 .to_ascii_lowercase();
1351 let tid = stack.template_id.to_ascii_lowercase();
1352 name.contains(&f)
1353 || tid.contains(&f)
1354 || stack
1355 .contents
1356 .iter()
1357 .any(|c| stack_matches_filter(c, filter))
1358}
1359
1360fn push_inventory_rows_filtered(
1361 rows: &mut Vec<InventoryRow>,
1362 depth: usize,
1363 stack: &flatland_protocol::ItemStack,
1364 from: &flatland_protocol::InventoryLocation,
1365 from_parent_instance_id: Option<uuid::Uuid>,
1366 section: InventorySection,
1367 filter: &str,
1368) {
1369 if !filter.is_empty() && !stack_matches_filter(stack, filter) {
1370 return;
1371 }
1372 let self_hit = filter.is_empty() || {
1373 let f = filter.to_ascii_lowercase();
1374 let name = stack
1375 .display_name
1376 .as_deref()
1377 .unwrap_or("")
1378 .to_ascii_lowercase();
1379 let tid = stack.template_id.to_ascii_lowercase();
1380 name.contains(&f) || tid.contains(&f)
1381 };
1382 rows.push(InventoryRow {
1383 depth,
1384 stack: stack.clone(),
1385 from: from.clone(),
1386 from_parent_instance_id,
1387 is_equip_shell: false,
1388 is_chest_shell: false,
1389 section,
1390 });
1391 for child in &stack.contents {
1392 if self_hit || filter.is_empty() || stack_matches_filter(child, filter) {
1393 push_inventory_rows_filtered(
1394 rows,
1395 depth + 1,
1396 child,
1397 from,
1398 stack.item_instance_id,
1399 section,
1400 if self_hit { "" } else { filter },
1401 );
1402 }
1403 }
1404}
1405
1406#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1407pub enum ShopTab {
1408 #[default]
1409 Buy,
1410 Sell,
1411}
1412
1413#[derive(Debug, Clone)]
1414pub struct NpcChatState {
1415 pub npc_id: String,
1416 pub npc_label: String,
1417 pub lines: Vec<String>,
1418 pub input: String,
1419 pub pending: bool,
1420 pub talk_depth: flatland_protocol::NpcTalkDepth,
1421 pub trade_allowed: bool,
1422 pub banner: Option<String>,
1423 pub suggested_topics: Vec<String>,
1424}
1425
1426impl Default for NpcChatState {
1427 fn default() -> Self {
1428 Self {
1429 npc_id: String::new(),
1430 npc_label: String::new(),
1431 lines: Vec::new(),
1432 input: String::new(),
1433 pending: false,
1434 talk_depth: flatland_protocol::NpcTalkDepth::Full,
1435 trade_allowed: true,
1436 banner: None,
1437 suggested_topics: Vec::new(),
1438 }
1439 }
1440}
1441
1442pub fn npc_world_xy(state: &GameState, npc: &NpcView) -> (f32, f32) {
1444 npc.entity_id
1445 .and_then(|eid| state.entities.iter().find(|e| e.id == eid))
1446 .map(|e| (e.transform.position.x, e.transform.position.y))
1447 .unwrap_or((npc.x, npc.y))
1448}
1449
1450#[derive(Debug, Clone)]
1451pub struct GameState {
1452 pub session_id: SessionId,
1453 pub entity_id: EntityId,
1454 pub character_id: Option<uuid::Uuid>,
1456 pub tick: Tick,
1457 pub chunk_rev: u64,
1458 pub content_rev: u64,
1459 pub publish_rev: u64,
1460 pub entities: Vec<EntityState>,
1461 pub player: Option<EntityState>,
1462 pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
1463 pub harvest_route_nodes: Vec<flatland_protocol::ResourceNodeView>,
1465 pub ground_drops: Vec<flatland_protocol::GroundDropView>,
1466 pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
1467 pub buildings: Vec<BuildingView>,
1468 pub doors: Vec<DoorView>,
1469 pub interior_map: Option<InteriorMapView>,
1470 pub npcs: Vec<NpcView>,
1471 pub blueprints: Vec<BlueprintView>,
1472 pub building_materials: Vec<flatland_protocol::BuildingMaterialView>,
1474 pub world_x0: f32,
1476 pub world_y0: f32,
1477 pub world_width_m: f32,
1478 pub world_height_m: f32,
1479 pub terrain_zones: Vec<TerrainZoneView>,
1480 pub z_platforms: Vec<ZPlatformView>,
1481 pub z_transitions: Vec<ZTransitionView>,
1482 #[doc(hidden)]
1485 pub z_bands_outdoor_backup: Option<(Vec<ZPlatformView>, Vec<ZTransitionView>)>,
1486 pub world_clock: flatland_protocol::WorldClock,
1487 pub inventory: std::collections::HashMap<String, u32>,
1488 pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1489 pub item_catalog: std::collections::HashMap<String, ItemCatalogEntryView>,
1491 pub logs: VecDeque<String>,
1492 pub intents_sent: u64,
1493 pub ticks_received: u64,
1494 pub connected: bool,
1495 pub disconnect_reason: Option<String>,
1496 pub show_stats: bool,
1497 pub hud_log_hidden: bool,
1499 pub show_equip_menu: bool,
1500 pub equip_menu_index: usize,
1501 pub show_craft_menu: bool,
1502 pub craft_menu_index: usize,
1503 pub craft_batch_quantity: u32,
1505 pub craft_tab: CraftTab,
1507 pub craft_filter: String,
1509 pub craft_filter_focused: bool,
1510 pub craft_prefs: crate::craft_prefs::CraftCharacterPrefs,
1512 pub show_plot_build_menu: bool,
1514 pub plot_build_focus_wall: bool,
1516 pub plot_build_wall_index: usize,
1517 pub plot_build_roof_index: usize,
1518 pub show_shop_menu: bool,
1519 pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1520 pub bank_panel: Option<flatland_protocol::BankPanel>,
1521 pub bank_menu_index: usize,
1522 pub bank_ui_mode: BankUiMode,
1523 pub storage_panel: Option<flatland_protocol::StoragePanel>,
1524 pub market_panel: Option<flatland_protocol::MarketPanel>,
1525 pub market_menu_index: usize,
1527 pub market_filter: String,
1529 pub market_filter_focused: bool,
1530 pub market_category_filter: Option<&'static str>,
1532 pub market_buy_confirm: Option<(uuid::Uuid, u32, u64, u64, String)>,
1534 pub market_ui_mode: MarketUiMode,
1535 pub storage_menu_index: usize,
1536 pub storage_ui_mode: StorageUiMode,
1537 pub shop_tab: ShopTab,
1538 pub shop_menu_index: usize,
1539 pub shop_quantity: u32,
1540 pub shop_trade_log: VecDeque<String>,
1542 pub show_npc_verb_menu: bool,
1543 pub npc_verb_target: Option<String>,
1544 pub npc_verb_index: usize,
1545 pub npc_verb_notice: Option<String>,
1547 pub player_verbs: crate::social::PlayerVerbState,
1549 pub social_chat: crate::social::SocialChatState,
1550 pub trade_ui: crate::social::TradeUiState,
1551 pub whisper_pouch_ui: crate::social::WhisperPouchUi,
1552 pub show_npc_chat: bool,
1553 pub npc_chat: Option<NpcChatState>,
1554 pub show_inventory_menu: bool,
1555 pub inventory_menu_index: usize,
1556 pub inventory_tab: InventoryTab,
1557 pub inventory_filter: String,
1558 pub inventory_filter_focused: bool,
1559 pub show_move_picker: bool,
1560 pub move_picker_index: usize,
1561 pub move_picker: Option<MovePicker>,
1562 pub show_grant_picker: bool,
1563 pub grant_picker_index: usize,
1564 pub grant_picker: Option<GrantTargetPicker>,
1565 pub show_destroy_picker: bool,
1566 pub destroy_confirm_pending: bool,
1567 pub destroy_picker: Option<DestroyPicker>,
1568 pub show_deconstruct_picker: bool,
1570 pub deconstruct_confirm_pending: bool,
1571 pub deconstruct_picker: Option<DestroyPicker>,
1572 pub show_rename_prompt: bool,
1574 pub rename_plot_id: Option<uuid::Uuid>,
1576 pub highlighted_plot_id: Option<uuid::Uuid>,
1578 pub show_worker_rename: bool,
1580 pub rename_buffer: String,
1581 pub combat_target: Option<EntityId>,
1583 pub combat_target_label: Option<String>,
1584 pub ground_target: Option<(f32, f32, f32)>,
1587 pub combat_fx: Vec<flatland_protocol::CombatFx>,
1589 pub ground_hazards: Vec<flatland_protocol::GroundHazardView>,
1591 pub property_zones: Vec<flatland_protocol::PropertyZoneView>,
1593 pub tax_zones: Vec<flatland_protocol::TaxZoneView>,
1595 pub growth_zones: Vec<flatland_protocol::GrowthZoneView>,
1597 pub biome_zones: Vec<flatland_protocol::BiomeZoneView>,
1599 pub terrain_kind_nav: Vec<flatland_protocol::TerrainKindNavView>,
1601 pub property_plots: Vec<flatland_protocol::PropertyPlotView>,
1603 pub property_plot_settings: Option<flatland_protocol::PropertyPlotSettingsView>,
1605 pub claim_mode: Option<ClaimModeState>,
1607 pub relocate_mode: Option<RelocateModeState>,
1609 pub sell_plot_confirm: Option<uuid::Uuid>,
1611 pub sell_plot_armed_at: Option<Instant>,
1613 pub show_plant_menu: bool,
1615 pub plant_menu_index: usize,
1616 pub show_farm_access: bool,
1618 pub farm_access_name_draft: String,
1620 pub farm_access_discount_bps: u32,
1622 pub farm_access_index: usize,
1624 pub plant_quantity: u32,
1625 pub in_combat: bool,
1626 pub auto_attack: bool,
1627 pub combat_has_los: bool,
1628 pub attack_cd_ticks: u64,
1629 pub gcd_ticks: u64,
1630 pub weapon_ability_id: String,
1631 pub mainhand_template_id: Option<String>,
1632 pub mainhand_label: Option<String>,
1633 pub mainhand_instance_id: Option<uuid::Uuid>,
1634 pub offhand_template_id: Option<String>,
1635 pub offhand_label: Option<String>,
1636 pub offhand_instance_id: Option<uuid::Uuid>,
1637 pub mainhand_hand_slots: u8,
1638 pub defense: Option<flatland_protocol::DefenseHud>,
1639 pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1641 pub carry_mass: f32,
1642 pub carry_mass_max: f32,
1643 pub encumbrance: flatland_protocol::EncumbranceState,
1644 pub move_speed_mps: f32,
1646 pub move_speed_mult: f32,
1648 pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1650 pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1652 pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
1654 pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1656 pub combat_target_detail: Option<CombatTargetHud>,
1657 pub cast_progress: Option<CastProgressHud>,
1658 pub timed_channel: Option<flatland_protocol::TimedChannelHud>,
1660 pub plot_build_offer: Option<flatland_protocol::PlotBuildOfferHud>,
1662 pub ability_cooldowns: Vec<AbilityCooldownHud>,
1663 pub blocking_active: bool,
1664 pub max_target_slots: u8,
1665 pub combat_slots: Vec<CombatSlotHud>,
1666 pub rotation_presets: Vec<RotationPreset>,
1667 pub known_abilities: Vec<String>,
1669 pub ability_meta: std::collections::HashMap<String, flatland_protocol::AbilityMetaHud>,
1671 pub ability_mastery: std::collections::HashMap<String, flatland_protocol::AbilityMasteryHud>,
1673 pub hotbar: Vec<Option<String>>,
1675 pub max_abilities_per_rotation: u8,
1677 pub show_loadout_menu: bool,
1678 pub show_keychain_menu: bool,
1679 pub keychain_menu_index: usize,
1680 pub show_rotation_editor: bool,
1681 pub loadout_menu_index: usize,
1683 pub loadout_hotbar_slot: u8,
1685 pub loadout_ability_index: usize,
1687 pub loadout_focus_presets: bool,
1689 pub rotation_editor: RotationEditorState,
1690 pub harvest_in_progress: bool,
1692 pub harvest_started_at: Option<Instant>,
1694 pub pending_craft_ack: Option<(u32, String, u32)>,
1696 pub craft_channel_blueprint_id: Option<String>,
1699 pub craft_channel_seen: bool,
1704 pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1705 pub interactables: Vec<flatland_protocol::InteractableView>,
1706 pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1707 pub career: Option<flatland_protocol::PlayerCareerView>,
1708 pub character_sheet_tab: CharacterSheetTab,
1709 pub ledger_period: LedgerPeriod,
1710 pub show_quest_offer: bool,
1711 pub pending_quest_offers: Vec<flatland_protocol::QuestOffer>,
1712 pub quest_offer_index: usize,
1713 pub show_quest_menu: bool,
1714 pub quest_menu_index: usize,
1715 pub quest_withdraw_confirm: bool,
1716 pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1717 pub show_workers_menu: bool,
1718 pub workers_menu_index: usize,
1719 pub worker_dismiss_confirmation: Option<WorkerDismissConfirmation>,
1720 pub workers_menu_compact: bool,
1722 pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1725 pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1727 pub worker_health_ring_until: BTreeMap<EntityId, Instant>,
1729 pub pending_worker_hire_since: Option<Instant>,
1731 pub show_worker_give_picker: bool,
1733 pub worker_give_picker_index: usize,
1734 pub worker_give_picker: Option<WorkerGivePicker>,
1735 pub show_worker_give_target_picker: bool,
1737 pub worker_give_target_picker_index: usize,
1738 pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1739 pub show_worker_take_picker: bool,
1741 pub worker_take_picker_index: usize,
1742 pub worker_take_picker: Option<WorkerTakePicker>,
1743 pub show_worker_teach_picker: bool,
1745 pub worker_teach_picker_index: usize,
1746 pub worker_teach_picker: Option<WorkerTeachPicker>,
1747 pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1749 pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1751 pub attending_worker_instance_id: Option<String>,
1753 pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1755}
1756
1757#[derive(Debug, Clone, PartialEq, Eq)]
1758pub enum NpcVerbAction {
1759 Talk,
1760 Trade,
1761 Bank,
1762 Storage,
1763 Market,
1764 QuestTalk { quest_id: String },
1765 QuestGive { quest_id: String },
1766}
1767
1768#[derive(Debug, Clone, PartialEq, Eq)]
1769pub struct NpcVerbChoice {
1770 pub label: String,
1771 pub action: NpcVerbAction,
1772}
1773
1774impl std::fmt::Display for NpcVerbChoice {
1775 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1776 f.write_str(&self.label)
1777 }
1778}
1779
1780impl GameState {
1781 pub fn selected_quest_offer(&self) -> Option<&flatland_protocol::QuestOffer> {
1782 self.pending_quest_offers.get(self.quest_offer_index)
1783 }
1784
1785 pub fn push_quest_offer(&mut self, offer: flatland_protocol::QuestOffer) {
1786 if self
1787 .pending_quest_offers
1788 .iter()
1789 .any(|existing| existing.quest_id == offer.quest_id)
1790 {
1791 self.show_quest_offer = true;
1792 return;
1793 }
1794 self.pending_quest_offers.push(offer);
1795 self.show_quest_offer = true;
1796 }
1797
1798 pub fn remove_quest_offer(&mut self, quest_id: &str) {
1799 self.pending_quest_offers
1800 .retain(|offer| offer.quest_id != quest_id);
1801 if self.pending_quest_offers.is_empty() {
1802 self.show_quest_offer = false;
1803 self.quest_offer_index = 0;
1804 return;
1805 }
1806 self.quest_offer_index = self
1807 .quest_offer_index
1808 .min(self.pending_quest_offers.len() - 1);
1809 self.show_quest_offer = true;
1810 }
1811
1812 pub fn clear_quest_offers(&mut self) {
1813 self.pending_quest_offers.clear();
1814 self.quest_offer_index = 0;
1815 self.show_quest_offer = false;
1816 }
1817
1818 pub fn move_quest_offer_selection(&mut self, delta: i32) {
1819 let n = self.pending_quest_offers.len();
1820 if n == 0 {
1821 self.quest_offer_index = 0;
1822 return;
1823 }
1824 let idx = self.quest_offer_index as i32;
1825 self.quest_offer_index = (idx + delta).rem_euclid(n as i32) as usize;
1826 }
1827
1828 pub fn push_log(&mut self, line: impl Into<String>) {
1829 self.logs.push_back(line.into());
1830 while self.logs.len() > MAX_LOG_LINES {
1831 self.logs.pop_front();
1832 }
1833 }
1834
1835 pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1836 self.shop_trade_log.push_back(line.into());
1837 while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1838 self.shop_trade_log.pop_front();
1839 }
1840 }
1841
1842 pub fn clear_shop_trade_log(&mut self) {
1843 self.shop_trade_log.clear();
1844 }
1845
1846 fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1847 if !self.show_shop_menu {
1848 return;
1849 }
1850 let msg = notice.message.trim();
1851 if msg.is_empty() {
1852 return;
1853 }
1854 if notice.coins_delta != 0
1855 || msg.starts_with("Bought ")
1856 || msg.starts_with("Sold ")
1857 || msg.contains("taught you how to craft")
1858 || msg.starts_with("need ")
1859 {
1860 self.push_shop_trade_log(msg);
1861 }
1862 }
1863
1864 pub fn is_alive(&self) -> bool {
1865 self.player
1866 .as_ref()
1867 .and_then(|p| p.vitals)
1868 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1869 .unwrap_or(true)
1870 }
1871
1872 pub fn push_audio(&mut self, cue: crate::social::AudioCue) {
1873 self.social_chat.push_cue(cue);
1874 }
1875
1876 fn sync_gameplay_audio(&mut self) {
1878 use crate::social::AudioCue;
1879 use flatland_protocol::PrimaryAttributes;
1880
1881 let alive = self.is_alive();
1882 let casting = self.cast_progress.is_some();
1883 let telegraph = self.focus_attack_telegraph_active();
1884 let in_aoe = self.player_inside_spatial_telegraph();
1885 let quest_sig = self.quest_audio_signature();
1886 let entity_id = self.entity_id;
1887 let char_level = self
1888 .player
1889 .as_ref()
1890 .and_then(|p| p.attributes)
1891 .map(|a| {
1892 PrimaryAttributes::display(a.strength)
1893 .saturating_add(PrimaryAttributes::display(a.dexterity))
1894 .saturating_add(PrimaryAttributes::display(a.intelligence))
1895 .saturating_add(PrimaryAttributes::display(a.stamina))
1896 .saturating_add(PrimaryAttributes::display(a.vitality))
1897 .saturating_add(PrimaryAttributes::display(a.wisdom))
1898 .saturating_add(PrimaryAttributes::display(a.charisma))
1899 })
1900 .unwrap_or(0);
1901
1902 let fx_ids: Vec<u64> = self.combat_fx.iter().map(|fx| fx.id).collect();
1903 let mut hit_cues = Vec::new();
1904 {
1905 let seen = &self.social_chat.audio_seen_fx_ids;
1906 for fx in &self.combat_fx {
1907 if seen.contains(&fx.id) {
1908 continue;
1909 }
1910 let Some(hit) = fx.hits.iter().find(|h| h.entity_id == entity_id) else {
1911 continue;
1912 };
1913 if hit.outcome == CombatFxHitOutcome::Blocked {
1914 hit_cues.push(AudioCue::CombatBlock);
1915 } else {
1916 let heavy = matches!(
1917 fx.kind,
1918 CombatFxKind::Sphere | CombatFxKind::Cone | CombatFxKind::Beam
1919 );
1920 hit_cues.push(if heavy {
1921 AudioCue::CombatHitHeavy
1922 } else {
1923 AudioCue::CombatHitLight
1924 });
1925 }
1926 }
1927 }
1928
1929 let audio = &mut self.social_chat;
1930 if !audio.audio_bootstrapped {
1931 audio.audio_was_alive = alive;
1932 audio.audio_was_casting = casting;
1933 audio.audio_had_target_telegraph = telegraph;
1934 audio.audio_was_in_aoe = in_aoe;
1935 audio.audio_quest_sig = quest_sig;
1936 audio.audio_char_level = char_level;
1937 audio.audio_seen_fx_ids = fx_ids;
1938 audio.audio_bootstrapped = true;
1939 return;
1940 }
1941
1942 if telegraph && !audio.audio_had_target_telegraph {
1943 audio.push_cue(AudioCue::CombatTelegraphStart);
1944 } else if !telegraph && audio.audio_had_target_telegraph {
1945 audio.push_cue(AudioCue::CombatTelegraphImpact);
1946 }
1947 audio.audio_had_target_telegraph = telegraph;
1948
1949 if in_aoe && !audio.audio_was_in_aoe {
1950 audio.push_cue(AudioCue::CombatAoeWarn);
1951 }
1952 audio.audio_was_in_aoe = in_aoe;
1953
1954 if casting && !audio.audio_was_casting {
1955 audio.push_cue(AudioCue::AbilityCastSelf);
1956 }
1957 audio.audio_was_casting = casting;
1958
1959 if !alive && audio.audio_was_alive {
1960 audio.push_cue(AudioCue::PlayerDeath);
1961 }
1962 audio.audio_was_alive = alive;
1963
1964 if quest_sig != audio.audio_quest_sig && audio.audio_quest_sig != 0 {
1965 audio.push_cue(AudioCue::QuestUpdate);
1966 }
1967 audio.audio_quest_sig = quest_sig;
1968
1969 if char_level > audio.audio_char_level && audio.audio_char_level > 0 {
1970 audio.push_cue(AudioCue::LevelUp);
1971 }
1972 audio.audio_char_level = char_level;
1973
1974 for cue in hit_cues {
1975 audio.push_cue(cue);
1976 }
1977 audio.audio_seen_fx_ids = fx_ids;
1978 }
1979
1980 fn focus_attack_telegraph_active(&self) -> bool {
1981 let Some(tid) = self.combat_target else {
1982 return false;
1983 };
1984 self.entities
1985 .iter()
1986 .find(|e| e.id == tid)
1987 .map(|e| {
1988 e.combat_cues.iter().any(|c| {
1989 matches!(c.kind, CombatCueKind::AttackTelegraph) && c.until_tick > self.tick
1990 })
1991 })
1992 .unwrap_or(false)
1993 }
1994
1995 fn player_inside_spatial_telegraph(&self) -> bool {
1996 let (px, py) = self.player_position();
1997 for e in &self.entities {
1998 for cue in &e.combat_cues {
1999 if !matches!(cue.kind, CombatCueKind::AttackTelegraph)
2000 || cue.until_tick <= self.tick
2001 {
2002 continue;
2003 }
2004 let Some(kind) = cue.telegraph_kind else {
2005 continue;
2006 };
2007 let (ox, oy) = match (cue.origin_x, cue.origin_y) {
2008 (Some(x), Some(y)) => (x, y),
2009 _ => continue,
2010 };
2011 match kind {
2012 CombatFxKind::Sphere => {
2013 let r = cue.radius_m.unwrap_or(1.0);
2014 let dx = px - ox;
2015 let dy = py - oy;
2016 if dx * dx + dy * dy <= r * r {
2017 return true;
2018 }
2019 }
2020 CombatFxKind::Cone | CombatFxKind::MeleeArc => {
2021 let reach = cue.reach_m.unwrap_or(2.0);
2022 let yaw = cue.yaw.unwrap_or(0.0);
2023 let arc = cue.arc_deg.unwrap_or(90.0).to_radians();
2024 let dx = px - ox;
2025 let dy = py - oy;
2026 let dist = (dx * dx + dy * dy).sqrt();
2027 if dist > reach || dist < 0.05 {
2028 continue;
2029 }
2030 let ang = dx.atan2(dy);
2031 let mut delta = ang - yaw;
2032 while delta > std::f32::consts::PI {
2033 delta -= std::f32::consts::TAU;
2034 }
2035 while delta < -std::f32::consts::PI {
2036 delta += std::f32::consts::TAU;
2037 }
2038 if delta.abs() <= arc * 0.5 {
2039 return true;
2040 }
2041 }
2042 _ => {}
2043 }
2044 }
2045 }
2046 false
2047 }
2048
2049 fn quest_audio_signature(&self) -> u64 {
2050 use std::collections::hash_map::DefaultHasher;
2051 use std::hash::{Hash, Hasher};
2052 let mut h = DefaultHasher::new();
2053 for q in &self.quest_log {
2054 q.quest_id.hash(&mut h);
2055 format!("{:?}", q.status).hash(&mut h);
2056 q.current_step_id.hash(&mut h);
2057 for o in &q.objectives {
2058 o.done.hash(&mut h);
2059 o.current.hash(&mut h);
2060 }
2061 }
2062 h.finish()
2063 }
2064
2065 pub fn npc_verb_options(&self) -> Vec<NpcVerbChoice> {
2067 let Some(ref id) = self.npc_verb_target else {
2068 return vec![];
2069 };
2070 let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
2071 return self.with_quest_verbs(id, vec![Self::talk_choice()]);
2072 };
2073 let role = npc.role.as_str();
2074 let rest = if Self::npc_role_is_bank(role) {
2075 vec![
2076 NpcVerbChoice {
2077 label: "Bank".into(),
2078 action: NpcVerbAction::Bank,
2079 },
2080 Self::talk_choice(),
2081 ]
2082 } else if Self::npc_role_is_storage(role) {
2083 vec![
2084 NpcVerbChoice {
2085 label: "Storage".into(),
2086 action: NpcVerbAction::Storage,
2087 },
2088 Self::talk_choice(),
2089 ]
2090 } else if Self::npc_role_is_market(role) {
2091 vec![
2092 NpcVerbChoice {
2093 label: "Market".into(),
2094 action: NpcVerbAction::Market,
2095 },
2096 Self::talk_choice(),
2097 ]
2098 } else if npc.can_trade || Self::npc_role_can_trade(role) {
2099 vec![
2100 Self::talk_choice(),
2101 NpcVerbChoice {
2102 label: "Trade".into(),
2103 action: NpcVerbAction::Trade,
2104 },
2105 ]
2106 } else {
2107 vec![Self::talk_choice()]
2108 };
2109 self.with_quest_verbs(id, rest)
2110 }
2111
2112 fn talk_choice() -> NpcVerbChoice {
2113 NpcVerbChoice {
2114 label: "Talk".into(),
2115 action: NpcVerbAction::Talk,
2116 }
2117 }
2118
2119 fn with_quest_verbs(&self, npc_id: &str, rest: Vec<NpcVerbChoice>) -> Vec<NpcVerbChoice> {
2120 let mut opts = self.quest_verb_choices(npc_id);
2121 opts.extend(rest);
2122 opts
2123 }
2124
2125 fn quest_verb_choices(&self, npc_id: &str) -> Vec<NpcVerbChoice> {
2126 if let Some(npc) = self.npcs.iter().find(|n| n.id == npc_id) {
2127 if !npc.quest_verbs.is_empty() {
2128 return npc
2129 .quest_verbs
2130 .iter()
2131 .map(|v| {
2132 let action = if v.kind == flatland_protocol::NpcQuestVerb::KIND_GIVE {
2133 NpcVerbAction::QuestGive {
2134 quest_id: v.quest_id.clone(),
2135 }
2136 } else {
2137 NpcVerbAction::QuestTalk {
2138 quest_id: v.quest_id.clone(),
2139 }
2140 };
2141 NpcVerbChoice {
2142 label: v.label.clone(),
2143 action,
2144 }
2145 })
2146 .collect();
2147 }
2148 }
2149 let catalog_ref = self.npc_quest_catalog_ref(npc_id);
2150 let mut opts = Vec::new();
2151 for q in &self.quest_log {
2152 if q.status != flatland_protocol::QuestStatusView::Active {
2153 continue;
2154 }
2155 let title = if q.title.trim().is_empty() {
2156 "Quest".to_string()
2157 } else {
2158 q.title.clone()
2159 };
2160 for o in &q.objectives {
2161 if o.done || o.npc_ref.as_deref() != Some(catalog_ref.as_str()) {
2162 continue;
2163 }
2164 if o.kind == "give_item" {
2165 opts.push(NpcVerbChoice {
2166 label: format!("Turn in: {title}"),
2167 action: NpcVerbAction::QuestGive {
2168 quest_id: q.quest_id.clone(),
2169 },
2170 });
2171 } else if o.kind == "talk_npc" {
2172 opts.push(NpcVerbChoice {
2173 label: title.clone(),
2174 action: NpcVerbAction::QuestTalk {
2175 quest_id: q.quest_id.clone(),
2176 },
2177 });
2178 }
2179 }
2180 }
2181 opts
2182 }
2183
2184 fn npc_quest_catalog_ref(&self, npc_id: &str) -> String {
2185 self.npcs
2186 .iter()
2187 .find(|n| n.id == npc_id)
2188 .and_then(|n| n.paperdoll_ref.clone())
2189 .unwrap_or_else(|| npc_id.to_string())
2190 }
2191
2192 fn count_inventory_template(&self, template: &str) -> u32 {
2193 self.inventory_stacks
2194 .iter()
2195 .filter(|s| s.template_id == template)
2196 .map(|s| s.quantity)
2197 .sum()
2198 }
2199
2200 fn npc_role_can_trade(role: &str) -> bool {
2201 matches!(
2202 role,
2203 "broker"
2204 | "cook"
2205 | "farmer"
2206 | "merchant"
2207 | "smith"
2208 | "blacksmith"
2209 | "woodworker"
2210 | "butcher"
2211 | "armorer"
2212 | "weaponsmith"
2213 | "wanderer"
2214 )
2215 }
2216
2217 fn npc_role_is_bank(role: &str) -> bool {
2218 role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
2219 }
2220
2221 fn npc_role_is_storage(role: &str) -> bool {
2222 role.eq_ignore_ascii_case("storage_manager")
2223 }
2224
2225 fn npc_role_is_market(role: &str) -> bool {
2226 role.eq_ignore_ascii_case("market_clerk")
2227 }
2228
2229 pub fn bank_menu_options(&self) -> Vec<&'static str> {
2230 vec![
2231 "Deposit…",
2232 "Withdraw…",
2233 "Deposit all",
2234 "Withdraw all",
2235 "Transfer…",
2236 ]
2237 }
2238
2239 pub fn storage_menu_options(&self) -> Vec<String> {
2240 let mut opts = vec!["Store…".into(), "Take…".into()];
2241 if let Some(panel) = &self.storage_panel {
2242 for dest in &panel.ship_destinations {
2243 opts.push(format!(
2244 "Ship → {} ({} cp / {} ticks)",
2245 dest.label, dest.fee_copper, dest.travel_ticks
2246 ));
2247 }
2248 }
2249 opts
2250 }
2251
2252 pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
2256 let equipped = self.hand_equipped_instance_ids();
2257 self.person_rows()
2258 .into_iter()
2259 .filter(|r| r.depth == 0)
2260 .filter_map(|r| {
2261 let id = r.stack.item_instance_id?;
2262 if equipped.contains(&id) {
2263 return None;
2264 }
2265 Some(StoragePickOption {
2266 item_instance_id: id,
2267 template_id: r.stack.template_id.clone(),
2268 label: storage_stack_label(&r.stack),
2269 quantity: r.stack.quantity,
2270 category: r.stack.category.clone().unwrap_or_default(),
2271 })
2272 })
2273 .collect()
2274 }
2275
2276 pub fn hand_equipped_instance_ids(&self) -> std::collections::HashSet<uuid::Uuid> {
2278 let mut ids = std::collections::HashSet::new();
2279 if let Some(id) = self.mainhand_instance_id {
2280 ids.insert(id);
2281 } else if let Some(tid) = &self.mainhand_template_id {
2282 if let Some(id) = self
2283 .inventory_stacks
2284 .iter()
2285 .find(|s| &s.template_id == tid)
2286 .and_then(|s| s.item_instance_id)
2287 {
2288 ids.insert(id);
2289 }
2290 }
2291 if let Some(id) = self.offhand_instance_id {
2292 ids.insert(id);
2293 } else if let Some(tid) = &self.offhand_template_id {
2294 if let Some(id) = self
2295 .inventory_stacks
2296 .iter()
2297 .find(|s| {
2298 &s.template_id == tid
2299 && s.item_instance_id.is_some_and(|iid| !ids.contains(&iid))
2300 })
2301 .and_then(|s| s.item_instance_id)
2302 {
2303 ids.insert(id);
2304 }
2305 }
2306 ids
2307 }
2308
2309 pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
2311 let Some(panel) = &self.storage_panel else {
2312 return Vec::new();
2313 };
2314 panel
2315 .contents
2316 .iter()
2317 .filter_map(|s| {
2318 let id = s.item_instance_id?;
2319 Some(StoragePickOption {
2320 item_instance_id: id,
2321 template_id: s.template_id.clone(),
2322 label: storage_stack_label(s),
2323 quantity: s.quantity,
2324 category: s.category.clone().unwrap_or_default(),
2325 })
2326 })
2327 .collect()
2328 }
2329
2330 pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
2332 let mut opts = Vec::new();
2333 if !self
2334 .market_list_item_options(&MarketListSourceKind::Person)
2335 .is_empty()
2336 {
2337 opts.push((MarketListSourceKind::Person, "On person".into()));
2338 }
2339 if let Some(panel) = &self.market_panel {
2340 for vault in &panel.list_vaults {
2341 let source = MarketListSourceKind::TownStorage {
2342 building_id: vault.building_id.clone(),
2343 };
2344 if self.market_list_item_options(&source).is_empty() {
2345 continue;
2346 }
2347 let label = if vault.building_label.is_empty() {
2348 format!("Town storage ({})", vault.building_id)
2349 } else {
2350 format!("Town storage — {}", vault.building_label)
2351 };
2352 opts.push((source, label));
2353 }
2354 }
2355 opts
2356 }
2357
2358 pub fn market_list_item_options(
2360 &self,
2361 source: &MarketListSourceKind,
2362 ) -> Vec<StoragePickOption> {
2363 let filter = self.market_filter.as_str();
2364 let cat_filter = self.market_category_filter;
2365 let mut opts: Vec<StoragePickOption> = match source {
2366 MarketListSourceKind::Person => {
2367 let equipped = self.hand_equipped_instance_ids();
2368 self.person_rows()
2369 .into_iter()
2370 .filter(|r| r.depth == 0)
2371 .filter(|r| self.stack_is_market_listable(&r.stack))
2372 .filter_map(|r| {
2373 let id = r.stack.item_instance_id?;
2374 if equipped.contains(&id) {
2375 return None;
2376 }
2377 Some(StoragePickOption {
2378 item_instance_id: id,
2379 template_id: r.stack.template_id.clone(),
2380 label: storage_stack_label(&r.stack),
2381 quantity: r.stack.quantity,
2382 category: r
2383 .stack
2384 .category
2385 .clone()
2386 .or_else(|| {
2387 self.inventory_item_category(&r.stack.template_id)
2388 .map(str::to_string)
2389 })
2390 .unwrap_or_default(),
2391 })
2392 })
2393 .collect()
2394 }
2395 MarketListSourceKind::TownStorage { building_id } => {
2396 let Some(panel) = &self.market_panel else {
2397 return Vec::new();
2398 };
2399 let Some(vault) = panel
2400 .list_vaults
2401 .iter()
2402 .find(|v| &v.building_id == building_id)
2403 else {
2404 return Vec::new();
2405 };
2406 vault
2407 .contents
2408 .iter()
2409 .filter(|s| self.stack_is_market_listable(s))
2410 .filter_map(|s| {
2411 let id = s.item_instance_id?;
2412 Some(StoragePickOption {
2413 item_instance_id: id,
2414 template_id: s.template_id.clone(),
2415 label: storage_stack_label(s),
2416 quantity: s.quantity,
2417 category: s
2418 .category
2419 .clone()
2420 .or_else(|| {
2421 self.inventory_item_category(&s.template_id)
2422 .map(str::to_string)
2423 })
2424 .unwrap_or_default(),
2425 })
2426 })
2427 .collect()
2428 }
2429 };
2430 opts.retain(|o| {
2431 if !list_label_matches(&o.label, filter) {
2432 return false;
2433 }
2434 if let Some(group) = cat_filter {
2435 inventory_category_group(&o.category).0 == group
2436 } else {
2437 true
2438 }
2439 });
2440 opts
2441 }
2442
2443 pub fn item_base_value_copper_hint(&self, template_id: &str) -> Option<u32> {
2445 if let Some(hint) = self.inventory_hints.get(template_id) {
2446 if let Some(v) = hint.base_value_copper.filter(|v| *v > 0) {
2447 return Some(v);
2448 }
2449 }
2450 if let Some(v) = self
2451 .inventory_stacks
2452 .iter()
2453 .find(|s| s.template_id == template_id)
2454 .and_then(|s| s.base_value_copper.filter(|v| *v > 0))
2455 {
2456 return Some(v);
2457 }
2458 self.market_panel.as_ref().and_then(|panel| {
2459 panel.list_vaults.iter().find_map(|vault| {
2460 vault.contents.iter().find_map(|stack| {
2461 (stack.template_id == template_id)
2462 .then(|| stack.base_value_copper.filter(|v| *v > 0))
2463 .flatten()
2464 })
2465 })
2466 })
2467 }
2468
2469 pub fn npc_market_dump_unit_estimate(&self, template_id: &str) -> Option<u32> {
2471 let base = self.item_base_value_copper_hint(template_id)?;
2472 npc_market_dump_unit_estimate_copper(base)
2473 }
2474
2475 fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
2476 if crate::currency::is_currency(&stack.template_id) {
2477 return false;
2478 }
2479 if let Some(flag) = stack.listable {
2480 return flag;
2481 }
2482 if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
2483 return hint.listable;
2484 }
2485 let cat = stack
2486 .category
2487 .as_deref()
2488 .or_else(|| self.inventory_item_category(&stack.template_id))
2489 .unwrap_or("");
2490 category_default_listable(cat)
2491 }
2492
2493 pub fn market_available_category_groups(&self) -> Vec<&'static str> {
2495 let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
2496 match &self.market_ui_mode {
2497 MarketUiMode::ListPick { source, .. } => {
2498 let raw: Vec<_> = match source {
2499 MarketListSourceKind::Person => self
2500 .person_rows()
2501 .into_iter()
2502 .filter(|r| r.depth == 0)
2503 .filter(|r| self.stack_is_market_listable(&r.stack))
2504 .filter(|r| {
2505 list_label_matches(&storage_stack_label(&r.stack), &self.market_filter)
2506 })
2507 .map(|r| {
2508 r.stack
2509 .category
2510 .clone()
2511 .or_else(|| {
2512 self.inventory_item_category(&r.stack.template_id)
2513 .map(str::to_string)
2514 })
2515 .unwrap_or_default()
2516 })
2517 .collect(),
2518 MarketListSourceKind::TownStorage { building_id } => self
2519 .market_panel
2520 .as_ref()
2521 .and_then(|p| p.list_vaults.iter().find(|v| &v.building_id == building_id))
2522 .map(|vault| {
2523 vault
2524 .contents
2525 .iter()
2526 .filter(|s| self.stack_is_market_listable(s))
2527 .filter(|s| {
2528 list_label_matches(&storage_stack_label(s), &self.market_filter)
2529 })
2530 .map(|s| {
2531 s.category
2532 .clone()
2533 .or_else(|| {
2534 self.inventory_item_category(&s.template_id)
2535 .map(str::to_string)
2536 })
2537 .unwrap_or_default()
2538 })
2539 .collect::<Vec<_>>()
2540 })
2541 .unwrap_or_default(),
2542 };
2543 for category in raw {
2544 let (label, ord) = inventory_category_group(&category);
2545 seen.insert(ord, label);
2546 }
2547 }
2548 _ => {
2549 if let Some(panel) = &self.market_panel {
2550 for listing in &panel.listings {
2551 if !list_label_matches(&listing.display_name, &self.market_filter)
2552 && !list_label_matches(&listing.seller_label, &self.market_filter)
2553 {
2554 continue;
2555 }
2556 let (label, ord) = inventory_category_group(&listing.category);
2557 seen.insert(ord, label);
2558 }
2559 }
2560 }
2561 }
2562 seen.into_values().collect()
2563 }
2564
2565 pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
2567 let Some(panel) = &self.market_panel else {
2568 return Vec::new();
2569 };
2570 let filter = self.market_filter.as_str();
2571 let cat_filter = self.market_category_filter;
2572 panel
2573 .listings
2574 .iter()
2575 .enumerate()
2576 .filter(|(_, listing)| {
2577 if !list_label_matches(&listing.display_name, filter)
2578 && !list_label_matches(&listing.seller_label, filter)
2579 && !list_label_matches(&listing.template_id, filter)
2580 {
2581 return false;
2582 }
2583 if let Some(group) = cat_filter {
2584 inventory_category_group(&listing.category).0 == group
2585 } else {
2586 true
2587 }
2588 })
2589 .map(|(i, _)| i)
2590 .collect()
2591 }
2592
2593 pub fn clear_harvest_state(&mut self) {
2594 self.harvest_in_progress = false;
2595 self.harvest_started_at = None;
2596 }
2597
2598 fn harvest_state_stale(&self) -> bool {
2599 match self.harvest_started_at {
2600 Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
2601 None => self.harvest_in_progress,
2602 }
2603 }
2604
2605 pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
2606 self.player.as_ref().and_then(|p| p.vitals)
2607 }
2608
2609 pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
2610 let materials_ok = blueprint.inputs.iter().all(|input| {
2611 self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
2612 });
2613 let tools_ok = blueprint
2614 .required_tools
2615 .iter()
2616 .all(|tool| self.player_has_craft_tool(&tool.item));
2617 let station_ok = match blueprint.station.as_deref() {
2618 None | Some("hand") => true,
2619 Some(tag) => self.player_at_station_tag(tag),
2620 };
2621 materials_ok && tools_ok && station_ok && self.craft_has_vessel_room_for_output(blueprint)
2622 }
2623
2624 pub fn player_has_craft_tool(&self, tool_template: &str) -> bool {
2626 if self.inventory.get(tool_template).copied().unwrap_or(0) >= 1 {
2627 return true;
2628 }
2629 let Some(player) = self.player.as_ref() else {
2630 return false;
2631 };
2632 let px = player.transform.position.x;
2633 let py = player.transform.position.y;
2634 const RANGE: f32 = 3.0;
2636 self.placed_containers.iter().any(|c| {
2637 if c.template_id != tool_template {
2638 return false;
2639 }
2640 if !self.placed_container_in_current_space(c) {
2641 return false;
2642 }
2643 let dx = c.x - px;
2644 let dy = c.y - py;
2645 dx * dx + dy * dy <= RANGE * RANGE
2646 })
2647 }
2648
2649 fn craft_output_needs_vessel(&self, blueprint: &BlueprintView) -> bool {
2651 matches!(
2652 self.inventory_item_category(&blueprint.output),
2653 Some("bulk") | Some("liquid")
2654 ) || matches!(
2655 blueprint.output.as_str(),
2656 "dirt" | "mud" | "sand" | "water" | "milk"
2657 )
2658 }
2659
2660 fn craft_output_category(&self, blueprint: &BlueprintView) -> Option<&str> {
2661 self.inventory_item_category(&blueprint.output).or_else(|| {
2662 match blueprint.output.as_str() {
2663 "dirt" | "mud" | "sand" => Some("bulk"),
2664 "water" | "milk" => Some("liquid"),
2665 _ => None,
2666 }
2667 })
2668 }
2669
2670 fn craft_has_vessel_room_for_output(&self, blueprint: &BlueprintView) -> bool {
2671 if !self.craft_output_needs_vessel(blueprint) {
2672 return true;
2673 }
2674 let need = blueprint.output_qty.max(1);
2675 self.vessel_room_after_craft_inputs(blueprint) >= need
2676 }
2677
2678 fn vessel_room_after_craft_inputs(&self, blueprint: &BlueprintView) -> u32 {
2680 let mut stacks = self.inventory_stacks.clone();
2681 for worn in self.worn.values() {
2682 stacks.push(worn.clone());
2683 }
2684 for input in &blueprint.inputs {
2685 let mut left = input.quantity;
2686 drain_payload_from_stacks(&mut stacks, &input.template_id, &mut left);
2687 if left > 0 {
2688 return 0;
2689 }
2690 }
2691 vessel_room_for_payload_in_stacks(
2692 &stacks,
2693 &blueprint.output,
2694 self.craft_output_category(blueprint),
2695 )
2696 }
2697
2698 pub fn craft_vessel_status(&self, blueprint: &BlueprintView) -> CraftVesselStatus {
2700 let output_label = self.blueprint_output_label(blueprint);
2701 let needs_vessel = self.craft_output_needs_vessel(blueprint);
2702 let need_units = if needs_vessel {
2703 blueprint.output_qty.max(1)
2704 } else {
2705 0
2706 };
2707 let free_after_inputs = if needs_vessel {
2708 self.vessel_room_after_craft_inputs(blueprint)
2709 } else {
2710 0
2711 };
2712 let payload_cat = self.craft_output_category(blueprint);
2713 let mut vessels = Vec::new();
2714 Self::collect_craft_vessel_lines(
2715 &self.inventory_stacks,
2716 "pack",
2717 &blueprint.output,
2718 payload_cat,
2719 &mut vessels,
2720 );
2721 for worn in self.worn.values() {
2722 Self::collect_craft_vessel_lines(
2723 std::slice::from_ref(worn),
2724 "worn",
2725 &blueprint.output,
2726 payload_cat,
2727 &mut vessels,
2728 );
2729 }
2730 CraftVesselStatus {
2731 needs_vessel,
2732 output_label,
2733 need_units,
2734 free_after_inputs,
2735 ok: !needs_vessel || free_after_inputs >= need_units,
2736 vessels,
2737 }
2738 }
2739
2740 fn collect_craft_vessel_lines(
2741 stacks: &[flatland_protocol::ItemStack],
2742 location: &'static str,
2743 payload_id: &str,
2744 payload_category: Option<&str>,
2745 out: &mut Vec<CraftVesselLine>,
2746 ) {
2747 for stack in stacks {
2748 if is_serving_vessel_stack(stack) {
2749 let cap = serving_capacity_of(stack);
2750 let used = payload_units_in_vessel(stack);
2751 let free = vessel_free_room_for_payload(stack, payload_id, payload_category);
2752 let holds = stack
2753 .props
2754 .get("serving_holds")
2755 .cloned()
2756 .unwrap_or_else(|| {
2757 if stack.props.get("bulk_vessel").is_some_and(|v| v == "1")
2758 && stack.props.get("liquid_vessel").is_some_and(|v| v == "1")
2759 {
2760 "liquid,bulk".into()
2761 } else if stack.props.get("bulk_vessel").is_some_and(|v| v == "1") {
2762 "bulk".into()
2763 } else if stack.props.get("liquid_vessel").is_some_and(|v| v == "1") {
2764 "liquid".into()
2765 } else {
2766 "?".into()
2767 }
2768 });
2769 let label = stack
2770 .display_name
2771 .clone()
2772 .unwrap_or_else(|| stack.template_id.clone());
2773 out.push(CraftVesselLine {
2774 label,
2775 holds,
2776 capacity: cap,
2777 used,
2778 free,
2779 quantity: stack.quantity.max(1),
2780 accepts_output: free > 0,
2781 location,
2782 });
2783 }
2784 Self::collect_craft_vessel_lines(
2785 &stack.contents,
2786 location,
2787 payload_id,
2788 payload_category,
2789 out,
2790 );
2791 }
2792 }
2793
2794 fn craft_prefs_key(&self) -> String {
2795 if let Some(cid) = self.character_id {
2796 cid.to_string()
2797 } else if self.entity_id != 0 {
2798 format!("entity:{}", self.entity_id)
2799 } else {
2800 String::new()
2801 }
2802 }
2803
2804 pub fn reload_craft_prefs(&mut self) {
2805 let key = self.craft_prefs_key();
2806 self.craft_prefs = crate::craft_prefs::load_for_character(&key);
2807 }
2808
2809 fn persist_craft_prefs(&self) {
2810 crate::craft_prefs::save_for_character(&self.craft_prefs_key(), &self.craft_prefs);
2811 }
2812
2813 pub fn craft_known_tiers(&self) -> Vec<u32> {
2815 let mut tiers: Vec<u32> = self
2816 .blueprints
2817 .iter()
2818 .map(|bp| bp.craft_tier.max(1))
2819 .collect::<std::collections::BTreeSet<_>>()
2820 .into_iter()
2821 .collect();
2822 tiers.sort_unstable();
2823 tiers
2824 }
2825
2826 pub fn craft_tab_strip(&self) -> Vec<CraftTab> {
2828 let mut tabs = vec![CraftTab::Ready, CraftTab::Favorites, CraftTab::Recent];
2829 for t in self.craft_known_tiers() {
2830 tabs.push(CraftTab::Tier(t));
2831 }
2832 tabs
2833 }
2834
2835 pub fn craft_set_tab(&mut self, tab: CraftTab) {
2836 self.craft_tab = tab;
2837 self.craft_menu_index = 0;
2838 self.clamp_craft_menu_index();
2839 self.clamp_craft_batch_quantity();
2840 }
2841
2842 pub fn craft_cycle_tab(&mut self, delta: i32) {
2843 let tabs = self.craft_tab_strip();
2844 if tabs.is_empty() {
2845 return;
2846 }
2847 let cur = tabs.iter().position(|t| *t == self.craft_tab).unwrap_or(0) as i32;
2848 let next = (cur + delta).rem_euclid(tabs.len() as i32) as usize;
2849 self.craft_set_tab(tabs[next]);
2850 }
2851
2852 pub fn craft_matches_search(&self, bp: &BlueprintView) -> bool {
2853 let f = self.craft_filter.trim();
2854 if f.is_empty() {
2855 return true;
2856 }
2857 if list_label_matches(&bp.label, f)
2858 || list_label_matches(&bp.output, f)
2859 || list_label_matches(&bp.output_display_name, f)
2860 || bp
2861 .category
2862 .as_deref()
2863 .is_some_and(|c| list_label_matches(c, f))
2864 || bp
2865 .station
2866 .as_deref()
2867 .is_some_and(|s| list_label_matches(s, f))
2868 {
2869 return true;
2870 }
2871 bp.inputs.iter().any(|i| {
2872 list_label_matches(&i.template_id, f) || list_label_matches(&i.display_name, f)
2873 }) || bp
2874 .required_tools
2875 .iter()
2876 .any(|t| list_label_matches(&t.item, f) || list_label_matches(&t.display_name, f))
2877 }
2878
2879 pub fn craft_blueprint_in_channel(&self, blueprint_id: &str) -> bool {
2881 self.craft_channel_blueprint_id.as_deref() == Some(blueprint_id)
2882 }
2883
2884 fn sync_craft_ready_pin(&mut self) {
2885 if self.active_craft_channel().is_some() {
2886 self.craft_channel_seen = true;
2887 return;
2888 }
2889 if self.craft_channel_seen {
2890 self.clear_craft_ready_pin();
2891 }
2892 }
2893
2894 fn clear_craft_ready_pin(&mut self) {
2895 self.craft_channel_blueprint_id = None;
2896 self.craft_channel_seen = false;
2897 }
2898
2899 pub fn craft_filtered_indices(&self) -> Vec<usize> {
2901 let mut idxs: Vec<usize> = (0..self.blueprints.len())
2902 .filter(|&i| {
2903 let bp = &self.blueprints[i];
2904 if !self.craft_matches_search(bp) {
2905 return false;
2906 }
2907 match self.craft_tab {
2908 CraftTab::Ready => {
2909 self.can_craft_blueprint(bp) || self.craft_blueprint_in_channel(&bp.id)
2910 }
2911 CraftTab::Favorites => self.craft_prefs.is_favorite(&bp.id),
2912 CraftTab::Recent => self.craft_prefs.recent.iter().any(|id| id == &bp.id),
2913 CraftTab::Tier(t) => bp.craft_tier.max(1) == t,
2914 }
2915 })
2916 .collect();
2917 match self.craft_tab {
2918 CraftTab::Recent => {
2919 idxs.sort_by_key(|&i| {
2920 self.craft_prefs
2921 .recent
2922 .iter()
2923 .position(|id| id == &self.blueprints[i].id)
2924 .unwrap_or(usize::MAX)
2925 });
2926 }
2927 _ => {
2928 idxs.sort_by(|&a, &b| {
2929 let ba = &self.blueprints[a];
2930 let bb = &self.blueprints[b];
2931 let ia = self.craft_blueprint_in_channel(&ba.id);
2932 let ib = self.craft_blueprint_in_channel(&bb.id);
2933 ib.cmp(&ia)
2935 .then_with(|| {
2936 let ra = self.can_craft_blueprint(ba);
2937 let rb = self.can_craft_blueprint(bb);
2938 rb.cmp(&ra)
2939 })
2940 .then_with(|| {
2941 ba.label
2942 .to_ascii_lowercase()
2943 .cmp(&bb.label.to_ascii_lowercase())
2944 })
2945 });
2946 }
2947 }
2948 idxs
2949 }
2950
2951 pub fn craft_selected_blueprint(&self) -> Option<&BlueprintView> {
2952 let idxs = self.craft_filtered_indices();
2953 idxs.get(self.craft_menu_index)
2954 .and_then(|&i| self.blueprints.get(i))
2955 }
2956
2957 pub fn clamp_craft_menu_index(&mut self) {
2958 let n = self.craft_filtered_indices().len();
2959 if n == 0 {
2960 self.craft_menu_index = 0;
2961 } else {
2962 self.craft_menu_index = self.craft_menu_index.min(n - 1);
2963 }
2964 }
2965
2966 pub fn craft_is_favorite(&self, blueprint_id: &str) -> bool {
2967 self.craft_prefs.is_favorite(blueprint_id)
2968 }
2969
2970 pub fn craft_toggle_favorite_selected(&mut self) {
2971 let Some(id) = self.craft_selected_blueprint().map(|bp| bp.id.clone()) else {
2972 return;
2973 };
2974 self.craft_prefs.toggle_favorite(&id);
2975 self.persist_craft_prefs();
2976 if matches!(self.craft_tab, CraftTab::Favorites) {
2977 self.clamp_craft_menu_index();
2978 }
2979 }
2980
2981 pub fn craft_record_completed(&mut self, blueprint_id: &str) {
2982 self.craft_prefs.record_crafted(blueprint_id);
2983 self.persist_craft_prefs();
2984 }
2985
2986 pub fn focus_craft_filter(&mut self) {
2987 self.craft_filter_focused = true;
2988 }
2989
2990 pub fn append_craft_filter_char(&mut self, ch: char) {
2991 if !self.craft_filter_focused {
2992 return;
2993 }
2994 if is_list_filter_char(ch) {
2995 self.craft_filter.push(ch);
2996 self.craft_menu_index = 0;
2997 self.clamp_craft_menu_index();
2998 }
2999 }
3000
3001 pub fn craft_filter_backspace(&mut self) {
3002 if !self.craft_filter_focused {
3003 return;
3004 }
3005 self.craft_filter.pop();
3006 self.craft_menu_index = 0;
3007 self.clamp_craft_menu_index();
3008 }
3009
3010 pub fn clear_or_blur_craft_filter(&mut self) -> bool {
3012 if self.craft_filter_focused {
3013 if !self.craft_filter.is_empty() {
3014 self.craft_filter.clear();
3015 self.craft_menu_index = 0;
3016 self.clamp_craft_menu_index();
3017 } else {
3018 self.craft_filter_focused = false;
3019 }
3020 return true;
3021 }
3022 if !self.craft_filter.is_empty() {
3023 self.craft_filter.clear();
3024 self.craft_menu_index = 0;
3025 self.clamp_craft_menu_index();
3026 return true;
3027 }
3028 false
3029 }
3030
3031 pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
3032 if !self.can_craft_blueprint(blueprint) {
3033 return 0;
3034 }
3035 let mut limit = u32::MAX;
3036 for input in &blueprint.inputs {
3037 if input.quantity == 0 {
3038 continue;
3039 }
3040 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
3041 limit = limit.min(have / input.quantity);
3042 }
3043 for tool in &blueprint.required_tools {
3044 if tool.consumed {
3045 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
3046 limit = limit.min(have);
3047 }
3048 }
3049 if self.craft_output_needs_vessel(blueprint) {
3050 let need = blueprint.output_qty.max(1);
3051 let room = self.vessel_room_after_craft_inputs(blueprint);
3052 if need > 0 {
3053 limit = limit.min(room / need);
3054 }
3055 }
3056 limit.min(CRAFT_BATCH_SELECT_CAP)
3057 }
3058
3059 pub fn craft_stamina_batch_cap(&self) -> u32 {
3061 let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
3062 if CRAFT_STAMINA_COST > 0.0 {
3063 (stamina / CRAFT_STAMINA_COST).floor() as u32
3064 } else {
3065 u32::MAX
3066 }
3067 }
3068
3069 pub fn clamp_craft_batch_quantity(&mut self) {
3070 let Some(bp) = self.craft_selected_blueprint().cloned() else {
3071 self.craft_batch_quantity = 1;
3072 return;
3073 };
3074 let max = self.max_craft_batches(&bp).max(1);
3075 self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
3076 }
3077
3078 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
3079 let Some(bp) = self.craft_selected_blueprint().cloned() else {
3080 return;
3081 };
3082 let max = self.max_craft_batches(&bp).max(1);
3083 let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
3084 self.craft_batch_quantity = next as u32;
3085 }
3086
3087 pub fn craft_batch_set_max(&mut self) {
3088 let Some(bp) = self.craft_selected_blueprint().cloned() else {
3089 return;
3090 };
3091 let max = self.max_craft_batches(&bp);
3092 self.craft_batch_quantity = if max == 0 { 1 } else { max };
3093 }
3094
3095 pub fn craft_batch_set_min(&mut self) {
3096 self.craft_batch_quantity = 1;
3097 }
3098
3099 pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
3100 let preserve_ui = self.show_shop_menu;
3101 let tab = self.shop_tab;
3102 let index = self.shop_menu_index;
3103 let qty = self.shop_quantity;
3104
3105 self.show_shop_menu = true;
3106 self.bank_panel = None;
3107 self.show_craft_menu = false;
3108 self.show_inventory_menu = false;
3109 self.show_stats = false;
3110 if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
3111 self.npc_verb_target = Some(catalog.npc_id.clone());
3112 }
3113 self.shop_catalog = Some(catalog);
3114
3115 if preserve_ui {
3116 self.shop_tab = tab;
3117 self.shop_menu_index = index;
3118 self.shop_quantity = qty;
3119 } else {
3120 self.shop_tab = ShopTab::Buy;
3121 self.shop_menu_index = 0;
3122 self.shop_quantity = 1;
3123 self.clear_shop_trade_log();
3124 }
3125 self.show_npc_verb_menu = false;
3126 self.clamp_shop_selection();
3127 }
3128
3129 pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
3130 let same_teller = self
3131 .bank_panel
3132 .as_ref()
3133 .is_some_and(|p| p.npc_id == panel.npc_id);
3134 self.bank_panel = Some(panel);
3135 self.storage_panel = None;
3136 self.market_panel = None;
3137 self.shop_catalog = None;
3138 self.show_shop_menu = false;
3139 self.show_craft_menu = false;
3140 self.show_inventory_menu = false;
3141 self.show_stats = false;
3142 self.show_npc_verb_menu = false;
3143 self.show_npc_chat = false;
3144 self.npc_chat = None;
3145 if !same_teller {
3146 self.bank_menu_index = 0;
3147 self.bank_ui_mode = BankUiMode::Menu;
3148 }
3149 if let Some(panel) = &self.bank_panel {
3150 if self.npc_verb_target.is_none() {
3151 self.npc_verb_target = Some(panel.npc_id.clone());
3152 }
3153 }
3154 }
3155
3156 pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
3157 let same_manager = self
3158 .storage_panel
3159 .as_ref()
3160 .is_some_and(|p| p.npc_id == panel.npc_id);
3161 self.storage_panel = Some(panel);
3162 self.bank_panel = None;
3163 self.market_panel = None;
3164 self.bank_ui_mode = BankUiMode::Menu;
3165 self.shop_catalog = None;
3166 self.show_shop_menu = false;
3167 self.show_craft_menu = false;
3168 self.show_inventory_menu = false;
3169 self.show_stats = false;
3170 self.show_npc_verb_menu = false;
3171 self.show_npc_chat = false;
3172 self.npc_chat = None;
3173 if !same_manager {
3174 self.storage_menu_index = 0;
3175 self.storage_ui_mode = StorageUiMode::Menu;
3176 } else {
3177 self.clamp_storage_pick_index();
3178 }
3179 if let Some(panel) = &self.storage_panel {
3180 if self.npc_verb_target.is_none() {
3181 self.npc_verb_target = Some(panel.npc_id.clone());
3182 }
3183 }
3184 }
3185
3186 pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
3187 for vault in &panel.list_vaults {
3188 self.merge_stack_catalog_hints(&vault.contents);
3189 }
3190 self.market_panel = Some(panel);
3191 self.bank_panel = None;
3192 self.storage_panel = None;
3193 self.shop_catalog = None;
3194 self.show_shop_menu = false;
3195 self.show_craft_menu = false;
3196 self.show_inventory_menu = false;
3197 self.show_stats = false;
3198 self.show_npc_verb_menu = false;
3199 self.show_npc_chat = false;
3200 self.npc_chat = None;
3201 self.market_menu_index = 0;
3202 self.market_buy_confirm = None;
3203 self.market_ui_mode = MarketUiMode::Browse;
3204 self.market_filter.clear();
3205 self.market_filter_focused = false;
3206 self.market_category_filter = None;
3207 if let Some(panel) = &self.market_panel {
3208 if self.npc_verb_target.is_none() {
3209 self.npc_verb_target = Some(panel.npc_id.clone());
3210 }
3211 }
3212 }
3213
3214 pub fn clear_market_panel(&mut self) {
3215 self.market_panel = None;
3216 self.market_menu_index = 0;
3217 self.market_buy_confirm = None;
3218 self.market_ui_mode = MarketUiMode::Browse;
3219 self.market_filter.clear();
3220 self.market_filter_focused = false;
3221 self.market_category_filter = None;
3222 }
3223
3224 pub fn clear_bank_panel(&mut self) {
3225 self.bank_panel = None;
3226 self.bank_menu_index = 0;
3227 self.bank_ui_mode = BankUiMode::Menu;
3228 }
3229
3230 pub fn clear_storage_panel(&mut self) {
3231 self.storage_panel = None;
3232 self.storage_menu_index = 0;
3233 self.storage_ui_mode = StorageUiMode::Menu;
3234 }
3235
3236 fn clamp_storage_pick_index(&mut self) {
3237 match &self.storage_ui_mode {
3238 StorageUiMode::StorePick { index } => {
3239 let n = self.storage_store_options().len();
3240 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3241 self.storage_ui_mode = StorageUiMode::StorePick { index: next };
3242 }
3243 StorageUiMode::TakePick { index } => {
3244 let n = self.storage_vault_options().len();
3245 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3246 self.storage_ui_mode = StorageUiMode::TakePick { index: next };
3247 }
3248 StorageUiMode::ShipPick {
3249 dest_building_id,
3250 dest_label,
3251 index,
3252 } => {
3253 let n = self.storage_vault_options().len();
3254 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3255 self.storage_ui_mode = StorageUiMode::ShipPick {
3256 dest_building_id: dest_building_id.clone(),
3257 dest_label: dest_label.clone(),
3258 index: next,
3259 };
3260 }
3261 StorageUiMode::Menu
3262 | StorageUiMode::StoreAmount { .. }
3263 | StorageUiMode::TakeAmount { .. }
3264 | StorageUiMode::ShipAmount { .. } => {}
3265 }
3266 }
3267
3268 pub fn shop_list_len(&self) -> usize {
3269 let Some(catalog) = &self.shop_catalog else {
3270 return 0;
3271 };
3272 match self.shop_tab {
3273 ShopTab::Buy => catalog.sells.len(),
3274 ShopTab::Sell => catalog.buys.len(),
3275 }
3276 }
3277
3278 pub fn shop_menu_move(&mut self, delta: i32) {
3279 let n = self.shop_list_len();
3280 if n == 0 {
3281 return;
3282 }
3283 let idx = self.shop_menu_index as i32;
3284 let next = (idx + delta).rem_euclid(n as i32);
3285 self.shop_menu_index = next as usize;
3286 self.clamp_shop_quantity();
3287 }
3288
3289 pub fn shop_quantity_adjust(&mut self, delta: i32) {
3290 let max = self.shop_quantity_max();
3291 if max == 0 {
3292 self.shop_quantity = 0;
3293 return;
3294 }
3295 let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
3296 self.shop_quantity = next as u32;
3297 }
3298
3299 pub(crate) fn clamp_shop_selection(&mut self) {
3300 let n = self.shop_list_len();
3301 if n == 0 {
3302 self.shop_menu_index = 0;
3303 } else {
3304 self.shop_menu_index = self.shop_menu_index.min(n - 1);
3305 }
3306 self.clamp_shop_quantity();
3307 }
3308
3309 fn shop_quantity_max(&self) -> u32 {
3310 let Some(catalog) = &self.shop_catalog else {
3311 return 1;
3312 };
3313 match self.shop_tab {
3314 ShopTab::Buy => {
3315 if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
3316 if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
3317 return 1;
3318 }
3319 }
3320 99
3321 }
3322 ShopTab::Sell => catalog
3323 .buys
3324 .get(self.shop_menu_index)
3325 .map(|l| l.quantity)
3326 .unwrap_or(0),
3327 }
3328 }
3329
3330 pub fn shop_quantity_set_max(&mut self) {
3331 self.shop_quantity = self.shop_quantity_max();
3332 }
3333
3334 pub fn shop_quantity_set_min(&mut self) {
3335 let max = self.shop_quantity_max();
3336 self.shop_quantity = if max == 0 { 0 } else { 1 };
3337 }
3338
3339 fn clamp_shop_quantity(&mut self) {
3340 let max = self.shop_quantity_max();
3341 if max == 0 {
3342 self.shop_quantity = 0;
3343 } else {
3344 self.shop_quantity = self.shop_quantity.max(1).min(max);
3345 }
3346 }
3347
3348 pub fn player_at_station_tag(&self, tag: &str) -> bool {
3349 let Some(id) = self.effective_inside_building() else {
3350 return false;
3351 };
3352 self.buildings
3353 .iter()
3354 .find(|b| b.id == id)
3355 .is_some_and(|b| b.tags.iter().any(|t| t == tag))
3356 }
3357
3358 pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
3360 if self.can_craft_blueprint(blueprint) {
3361 return None;
3362 }
3363 let mut missing = Vec::new();
3364 for input in &blueprint.inputs {
3365 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
3366 if have < input.quantity {
3367 let name = self.blueprint_ingredient_label(input);
3368 let vessel_note = if self.inventory_item_category(&input.template_id)
3369 == Some("liquid")
3370 || matches!(input.template_id.as_str(), "water" | "milk")
3371 {
3372 "; fill a bottle/waterskin"
3373 } else if self.inventory_item_category(&input.template_id) == Some("bulk")
3374 || matches!(input.template_id.as_str(), "dirt" | "mud" | "sand")
3375 {
3376 "; scoop into a sack/bucket"
3377 } else {
3378 ""
3379 };
3380 missing.push(format!(
3381 "{}×{} (have {have}{vessel_note})",
3382 input.quantity, name
3383 ));
3384 }
3385 }
3386 for tool in &blueprint.required_tools {
3387 if !self.player_has_craft_tool(&tool.item) {
3388 missing.push(format!("tool: {}", self.blueprint_tool_label(tool)));
3389 }
3390 }
3391 if let Some(station) = blueprint.station.as_deref() {
3392 if station != "hand" && !self.player_at_station_tag(station) {
3393 missing.push(format!("station: {station} (enter building)"));
3394 }
3395 }
3396 if self.craft_output_needs_vessel(blueprint)
3397 && !self.craft_has_vessel_room_for_output(blueprint)
3398 {
3399 let name = self
3400 .inventory_hints
3401 .get(&blueprint.output)
3402 .map(|h| h.display_name.as_str())
3403 .unwrap_or(blueprint.output.as_str());
3404 let need = blueprint.output_qty.max(1);
3405 let free = self.vessel_room_after_craft_inputs(blueprint);
3406 let accepting = self
3407 .craft_vessel_status(blueprint)
3408 .vessels
3409 .iter()
3410 .filter(|v| v.accepts_output)
3411 .count();
3412 missing.push(format!(
3413 "vessel room for {name}: need {need} free after inputs, have {free} ({accepting} accepting vessel(s))"
3414 ));
3415 }
3416 if missing.is_empty() {
3417 None
3418 } else {
3419 Some(missing.join(", "))
3420 }
3421 }
3422
3423 pub fn active_craft_channel(&self) -> Option<&flatland_protocol::TimedChannelHud> {
3425 self.timed_channel
3426 .as_ref()
3427 .filter(|c| c.channel == flatland_protocol::TimedChannelKind::Craft)
3428 }
3429
3430 pub fn player_entity(&self) -> Option<&EntityState> {
3431 self.player
3432 .as_ref()
3433 .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
3434 }
3435
3436 pub fn apply_client_ui_prefs(&mut self) {
3438 let cfg = crate::client_config::ClientConfig::load();
3439 if let Some(hidden) = cfg.hud_log_hidden {
3440 self.hud_log_hidden = hidden;
3441 }
3442 if let Some(compact) = cfg.workers_menu_compact {
3443 self.workers_menu_compact = compact;
3444 }
3445 }
3446
3447 pub fn player_position(&self) -> (f32, f32) {
3448 let (x, y, _) = self.player_position_with_z();
3449 (x, y)
3450 }
3451
3452 pub fn player_position_with_z(&self) -> (f32, f32, f32) {
3453 if let Some(p) = self.player_entity() {
3454 (
3455 p.transform.position.x,
3456 p.transform.position.y,
3457 p.transform.position.z,
3458 )
3459 } else {
3460 (0.0, 0.0, 0.0)
3461 }
3462 }
3463
3464 pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
3465 let mut rows: Vec<(String, u32, String)> = self
3466 .inventory
3467 .iter()
3468 .filter(|(_, q)| **q > 0)
3469 .map(|(id, qty)| {
3470 let label = self
3471 .inventory_hints
3472 .get(id)
3473 .map(|h| h.display_name.clone())
3474 .unwrap_or_else(|| id.clone());
3475 (id.clone(), *qty, label)
3476 })
3477 .collect();
3478 rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
3479 rows
3480 }
3481
3482 pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
3483 self.inventory_hints
3484 .get(template_id)
3485 .map(|h| h.category.as_str())
3486 .filter(|c| !c.is_empty())
3487 }
3488
3489 pub fn stack_is_serving(stack: &flatland_protocol::ItemStack) -> bool {
3490 stack.props.get("serving").is_some_and(|v| v == "1") || Self::stack_is_liquid_vessel(stack)
3491 }
3492
3493 pub fn stack_is_liquid_vessel(stack: &flatland_protocol::ItemStack) -> bool {
3494 stack.props.get("liquid_vessel").is_some_and(|v| v == "1")
3495 || stack
3496 .props
3497 .get("serving_holds")
3498 .is_some_and(|v| v.split(',').any(|p| p.trim() == "liquid"))
3499 }
3500
3501 pub fn stack_is_food_serving(stack: &flatland_protocol::ItemStack) -> bool {
3502 stack
3503 .props
3504 .get("serving_holds")
3505 .is_some_and(|v| v.split(',').any(|p| p.trim() == "food"))
3506 }
3507
3508 pub fn stack_is_bulk_vessel(stack: &flatland_protocol::ItemStack) -> bool {
3509 stack.props.get("bulk_vessel").is_some_and(|v| v == "1")
3510 || stack
3511 .props
3512 .get("serving_holds")
3513 .is_some_and(|v| v.split(',').any(|p| p.trim() == "bulk"))
3514 }
3515
3516 pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
3517 stack
3518 .props
3519 .get("grants_item_status_effect")
3520 .map(|s| !s.is_empty())
3521 .unwrap_or(false)
3522 }
3523
3524 pub fn stack_is_blueprint_scroll(stack: &flatland_protocol::ItemStack) -> bool {
3525 stack
3526 .props
3527 .get("teaches_blueprint")
3528 .map(|s| !s.trim().is_empty())
3529 .unwrap_or(false)
3530 }
3531
3532 pub fn stack_is_deconstructable(stack: &flatland_protocol::ItemStack) -> bool {
3533 stack
3534 .props
3535 .get("deconstructable")
3536 .map(|s| s == "1")
3537 .unwrap_or(false)
3538 }
3539
3540 pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
3541 stack
3542 .props
3543 .get("grants_item_status_effect")
3544 .map(String::as_str)
3545 .filter(|s| !s.is_empty())
3546 }
3547
3548 pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
3549 stack
3550 .props
3551 .get("grants_item_status_mode")
3552 .map(String::as_str)
3553 .unwrap_or("on_hit")
3554 }
3555
3556 pub fn grant_target_options(
3558 &self,
3559 grant: &flatland_protocol::ItemStack,
3560 ) -> Vec<GrantTargetOption> {
3561 let mode = Self::grant_mode(grant);
3562 let grant_tags: Vec<&str> = grant
3563 .props
3564 .get("grants_item_status_tags")
3565 .map(|s| {
3566 s.split(',')
3567 .map(str::trim)
3568 .filter(|t| !t.is_empty())
3569 .collect()
3570 })
3571 .unwrap_or_default();
3572 let grant_id = grant.item_instance_id;
3573 let mut out = Vec::new();
3574 let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
3575 let Some(iid) = stack.item_instance_id else {
3576 return;
3577 };
3578 if Some(iid) == grant_id {
3579 return;
3580 }
3581 if stack.props.get("enchantable").map(String::as_str) == Some("0") {
3582 return;
3583 }
3584 if !grant_target_matches_mode(stack, mode) {
3585 return;
3586 }
3587 if !grant_tags_match(stack, &grant_tags) {
3588 return;
3589 }
3590 let name = stack
3591 .display_name
3592 .clone()
3593 .unwrap_or_else(|| stack.template_id.clone());
3594 let bindings = if stack.status_bindings.is_empty() {
3595 String::new()
3596 } else {
3597 format!(
3598 " · {}",
3599 stack
3600 .status_bindings
3601 .iter()
3602 .map(|b| b.effect_id.as_str())
3603 .collect::<Vec<_>>()
3604 .join(", ")
3605 )
3606 };
3607 out.push(GrantTargetOption {
3608 label: format!("{where_label}: {name}{bindings}"),
3609 target_instance_id: iid,
3610 });
3611 };
3612 fn walk(
3613 stacks: &[flatland_protocol::ItemStack],
3614 where_label: &str,
3615 push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
3616 ) {
3617 for s in stacks {
3618 push(s, where_label);
3619 if !s.contents.is_empty() {
3620 let nested = format!(
3621 "{where_label}/{}",
3622 s.display_name.as_deref().unwrap_or(s.template_id.as_str())
3623 );
3624 walk(&s.contents, &nested, push);
3625 }
3626 }
3627 }
3628 walk(&self.inventory_stacks, "Bag", &mut push);
3629 for (slot, stack) in &self.worn {
3630 push(stack, body_slot_label(*slot));
3631 let nest = format!(
3632 "{}/{}",
3633 body_slot_label(*slot),
3634 stack
3635 .display_name
3636 .as_deref()
3637 .unwrap_or(stack.template_id.as_str())
3638 );
3639 walk(&stack.contents, &nest, &mut push);
3640 }
3641 out
3642 }
3643
3644 pub fn item_base_mass(&self, template_id: &str) -> f32 {
3645 self.inventory_hints
3646 .get(template_id)
3647 .and_then(|h| h.base_mass)
3648 .unwrap_or(0.5)
3649 }
3650
3651 pub fn item_base_volume(&self, template_id: &str) -> f32 {
3652 self.inventory_hints
3653 .get(template_id)
3654 .and_then(|h| h.base_volume)
3655 .unwrap_or(1.0)
3656 }
3657
3658 pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
3659 let unit = stack
3660 .base_mass
3661 .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
3662 unit * stack.quantity as f32
3663 }
3664
3665 fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
3666 let unit = stack.base_volume.unwrap_or(1.0);
3667 unit * stack.quantity as f32
3668 + stack
3669 .contents
3670 .iter()
3671 .map(Self::stack_tree_volume)
3672 .sum::<f32>()
3673 }
3674
3675 fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
3676 contents.iter().map(Self::stack_tree_volume).sum()
3677 }
3678
3679 fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
3680 self.inventory_hints
3681 .get(template_id)
3682 .and_then(|h| h.capacity_volume)
3683 .filter(|c| *c > 0.0)
3684 }
3685
3686 fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
3687 stack
3688 .capacity_volume
3689 .filter(|c| *c > 0.0)
3690 .or_else(|| self.template_capacity_volume(&stack.template_id))
3691 }
3692
3693 pub fn container_volume_label(&self, row: &InventoryRow) -> String {
3695 let Some((used, cap)) = self.container_volume_stats(row) else {
3696 return String::new();
3697 };
3698 format!(" {}", format_container_volume_usage(used, cap))
3699 }
3700
3701 fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
3702 if row.is_chest_shell {
3703 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
3704 return None;
3705 };
3706 let chest = self
3707 .placed_containers
3708 .iter()
3709 .find(|c| c.id == *container_id)?;
3710 let cap = self
3711 .stack_capacity_volume(&row.stack)
3712 .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
3713 let used = if chest.accessible {
3714 Self::contents_used_volume(&chest.contents)
3715 } else {
3716 0.0
3717 };
3718 return Some((used, cap));
3719 }
3720
3721 let cap = self.stack_capacity_volume(&row.stack)?;
3722 let used = Self::contents_used_volume(&row.stack.contents);
3723 Some((used, cap))
3724 }
3725
3726 fn destination_volume_stats(
3727 &self,
3728 location: &flatland_protocol::InventoryLocation,
3729 parent_instance_id: Option<uuid::Uuid>,
3730 ) -> Option<(f32, f32)> {
3731 let parent = self.container_stack_for(location, parent_instance_id)?;
3732 let mut cap = self.stack_capacity_volume(&parent);
3733 if cap.is_none() {
3734 if let flatland_protocol::InventoryLocation::Placed { container_id } = location {
3735 if let Some(chest) = self
3736 .placed_containers
3737 .iter()
3738 .find(|c| c.id == *container_id)
3739 {
3740 let looking_at_shell =
3741 parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id));
3742 if looking_at_shell {
3743 cap = chest.capacity_volume.filter(|v| *v > 0.0);
3744 }
3745 }
3746 }
3747 }
3748 let cap = cap?;
3749 let used = Self::contents_used_volume(&parent.contents).max(0.0);
3750 Some((used, cap))
3751 }
3752
3753 pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
3754 if row.is_chest_shell {
3755 return true;
3756 }
3757 if row.is_equip_shell {
3758 return self.inventory_item_category(&row.stack.template_id) == Some("container");
3759 }
3760 self.inventory_item_category(&row.stack.template_id) == Some("container")
3761 || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
3762 }
3763
3764 fn container_stack_for(
3765 &self,
3766 location: &flatland_protocol::InventoryLocation,
3767 parent_instance_id: Option<uuid::Uuid>,
3768 ) -> Option<flatland_protocol::ItemStack> {
3769 match location {
3770 flatland_protocol::InventoryLocation::Root => {
3771 let pid = parent_instance_id?;
3772 self.find_stack_by_instance(&self.inventory_stacks, pid)
3773 }
3774 flatland_protocol::InventoryLocation::Worn { slot } => {
3775 let worn = self.worn.get(slot)?;
3776 if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
3777 Some(worn.clone())
3778 } else {
3779 self.find_stack_by_instance(&worn.contents, parent_instance_id?)
3780 }
3781 }
3782 flatland_protocol::InventoryLocation::Placed { container_id } => {
3783 let chest = self
3784 .placed_containers
3785 .iter()
3786 .find(|c| c.id == *container_id)?;
3787 if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
3788 Some(flatland_protocol::ItemStack {
3789 template_id: chest.template_id.clone(),
3790 quantity: 1,
3791 item_instance_id: chest.item_instance_id,
3792 props: Default::default(),
3793 status_bindings: Vec::new(),
3794 contents: chest.contents.clone(),
3795 display_name: Some(chest.display_name.clone()),
3796 category: Some("container".into()),
3797 capacity_volume: self
3798 .inventory_hints
3799 .get(&chest.template_id)
3800 .and_then(|h| h.capacity_volume),
3801 worker_lodging_capacity: chest.worker_lodging_capacity,
3802 ..Default::default()
3803 })
3804 } else {
3805 self.find_stack_by_instance(&chest.contents, parent_instance_id?)
3806 }
3807 }
3808 flatland_protocol::InventoryLocation::Keychain => None,
3809 flatland_protocol::InventoryLocation::WhisperPouch => None,
3810 }
3811 }
3812
3813 fn find_stack_by_instance(
3814 &self,
3815 stacks: &[flatland_protocol::ItemStack],
3816 instance_id: uuid::Uuid,
3817 ) -> Option<flatland_protocol::ItemStack> {
3818 for stack in stacks {
3819 if stack.item_instance_id == Some(instance_id) {
3820 return Some(stack.clone());
3821 }
3822 if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
3823 return Some(found);
3824 }
3825 }
3826 None
3827 }
3828
3829 pub fn max_movable_to(
3831 &self,
3832 template_id: &str,
3833 stack_qty: u32,
3834 from: &flatland_protocol::InventoryLocation,
3835 to: &flatland_protocol::InventoryLocation,
3836 parent_instance_id: Option<uuid::Uuid>,
3837 ) -> u32 {
3838 let unit_vol = self.item_base_volume(template_id);
3839 let mut limit = stack_qty;
3840
3841 if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
3842 let cap = parent
3843 .capacity_volume
3844 .or_else(|| {
3845 self.inventory_hints
3846 .get(&parent.template_id)
3847 .and_then(|h| h.capacity_volume)
3848 })
3849 .unwrap_or(0.0);
3850 if cap > 0.0 && unit_vol > 0.0 {
3851 let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
3852 limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
3853 }
3854 }
3855
3856 let _ = from;
3857 limit.max(0).min(stack_qty)
3858 }
3859
3860 pub fn move_picker_max_at_selection(&self) -> u32 {
3861 let Some(picker) = &self.move_picker else {
3862 return 1;
3863 };
3864 let Some(opt) = picker.options.get(self.move_picker_index) else {
3865 return picker.stack_quantity;
3866 };
3867 match &opt.kind {
3868 MoveOptionKind::Cancel
3869 | MoveOptionKind::Drop
3870 | MoveOptionKind::Use
3871 | MoveOptionKind::GrantApply
3872 | MoveOptionKind::SellPlotToCrown { .. }
3873 | MoveOptionKind::PickupPlaced { .. }
3874 | MoveOptionKind::RelocatePlaced { .. } => picker.stack_quantity,
3875 MoveOptionKind::Move {
3876 location,
3877 parent_instance_id,
3878 } => self.max_movable_to(
3879 &picker.template_id,
3880 picker.stack_quantity,
3881 &picker.from,
3882 location,
3883 *parent_instance_id,
3884 ),
3885 }
3886 }
3887
3888 pub fn clamp_move_picker_quantity(&mut self) {
3889 let max = self.move_picker_max_at_selection();
3890 if let Some(picker) = &mut self.move_picker {
3891 if max == 0 {
3892 picker.quantity = 1;
3893 } else {
3894 picker.quantity = picker.quantity.clamp(1, max);
3895 }
3896 }
3897 }
3898
3899 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
3900 let max = self.move_picker_max_at_selection().max(1);
3901 if let Some(picker) = &mut self.move_picker {
3902 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
3903 picker.quantity = next as u32;
3904 }
3905 }
3906
3907 pub fn move_picker_set_quantity_max(&mut self) {
3908 let max = self.move_picker_max_at_selection();
3909 if let Some(picker) = &mut self.move_picker {
3910 picker.quantity = if max == 0 {
3911 1
3912 } else {
3913 max.min(picker.stack_quantity)
3914 };
3915 }
3916 }
3917
3918 pub fn move_picker_set_quantity_min(&mut self) {
3919 if let Some(picker) = &mut self.move_picker {
3920 picker.quantity = 1;
3921 }
3922 }
3923
3924 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
3925 if let Some(picker) = &mut self.destroy_picker {
3926 let max = picker.stack_quantity.max(1);
3927 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
3928 picker.quantity = next as u32;
3929 }
3930 }
3931
3932 pub fn destroy_picker_set_quantity_max(&mut self) {
3933 if let Some(picker) = &mut self.destroy_picker {
3934 picker.quantity = picker.stack_quantity.max(1);
3935 }
3936 }
3937
3938 pub fn destroy_picker_set_quantity_min(&mut self) {
3939 if let Some(picker) = &mut self.destroy_picker {
3940 picker.quantity = 1;
3941 }
3942 }
3943
3944 pub fn deconstruct_picker_adjust_quantity(&mut self, delta: i32) {
3945 if let Some(picker) = &mut self.deconstruct_picker {
3946 let max = picker.stack_quantity.max(1);
3947 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
3948 picker.quantity = next as u32;
3949 }
3950 }
3951
3952 pub fn deconstruct_picker_set_quantity_max(&mut self) {
3953 if let Some(picker) = &mut self.deconstruct_picker {
3954 picker.quantity = picker.stack_quantity.max(1);
3955 }
3956 }
3957
3958 pub fn deconstruct_picker_set_quantity_min(&mut self) {
3959 if let Some(picker) = &mut self.deconstruct_picker {
3960 picker.quantity = 1;
3961 }
3962 }
3963
3964 pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
3965 let have = self.inventory.get(template_id).copied().unwrap_or(0);
3966 (have, have >= need)
3967 }
3968
3969 pub fn plot_build_stock_status(&self, template_id: &str, need: u32) -> (u32, bool) {
3971 let have = self
3972 .plot_build_offer
3973 .as_ref()
3974 .and_then(|o| {
3975 o.available
3976 .iter()
3977 .find(|s| s.template_id == template_id)
3978 .map(|s| s.quantity)
3979 })
3980 .unwrap_or_else(|| self.inventory.get(template_id).copied().unwrap_or(0));
3981 (have, have >= need)
3982 }
3983
3984 pub fn plot_build_wall_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3985 self.building_materials
3986 .iter()
3987 .filter(|m| m.can_wall)
3988 .collect()
3989 }
3990
3991 pub fn plot_build_roof_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3992 self.building_materials
3993 .iter()
3994 .filter(|m| m.can_roof)
3995 .collect()
3996 }
3997
3998 pub fn plot_build_selected_wall(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
3999 self.plot_build_wall_options()
4000 .get(self.plot_build_wall_index)
4001 .copied()
4002 }
4003
4004 pub fn plot_build_selected_roof(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
4005 self.plot_build_roof_options()
4006 .get(self.plot_build_roof_index)
4007 .copied()
4008 }
4009
4010 pub fn plot_build_bom_lines(&self) -> Vec<(String, String, u32)> {
4012 let Some(wall) = self.plot_build_selected_wall() else {
4013 return Vec::new();
4014 };
4015 let Some(roof) = self.plot_build_selected_roof() else {
4016 return Vec::new();
4017 };
4018 let area = self
4019 .plot_build_offer
4020 .as_ref()
4021 .filter(|o| o.pad_ok)
4022 .map(|o| o.pad_width_m * o.pad_depth_m)
4023 .unwrap_or(0.0);
4024 if area <= 0.0 {
4025 return Vec::new();
4026 }
4027 let mut map: std::collections::HashMap<String, (String, u32)> =
4028 std::collections::HashMap::new();
4029 for line in &wall.wall_bom {
4030 let qty = (area * line.per_m2).ceil().max(0.0) as u32;
4031 if qty == 0 {
4032 continue;
4033 }
4034 let name = if line.display_name.is_empty() {
4035 line.template_id.clone()
4036 } else {
4037 line.display_name.clone()
4038 };
4039 let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
4040 entry.1 = entry.1.saturating_add(qty);
4041 }
4042 for line in &roof.roof_bom {
4043 let qty = (area * line.per_m2).ceil().max(0.0) as u32;
4044 if qty == 0 {
4045 continue;
4046 }
4047 let name = if line.display_name.is_empty() {
4048 line.template_id.clone()
4049 } else {
4050 line.display_name.clone()
4051 };
4052 let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
4053 entry.1 = entry.1.saturating_add(qty);
4054 }
4055 let mut out: Vec<_> = map
4056 .into_iter()
4057 .map(|(id, (name, qty))| (id, name, qty))
4058 .collect();
4059 out.sort_by(|a, b| a.0.cmp(&b.0));
4060 out
4061 }
4062
4063 pub fn plot_build_duration_secs(&self) -> Option<f32> {
4064 let wall = self.plot_build_selected_wall()?;
4065 let roof = self.plot_build_selected_roof()?;
4066 let offer = self.plot_build_offer.as_ref()?;
4067 if !offer.pad_ok {
4068 return None;
4069 }
4070 let area = offer.pad_width_m * offer.pad_depth_m;
4071 let mult = wall.tick_mult.max(roof.tick_mult).max(0.1);
4072 let ticks = (offer.base_ticks as f32 + area * offer.tick_per_m2 as f32 * mult).ceil();
4073 Some(ticks.max(2.0) / 30.0)
4074 }
4075
4076 pub fn plot_build_can_afford(&self) -> bool {
4077 if self.plot_build_offer.as_ref().is_none_or(|o| !o.pad_ok) {
4078 return false;
4079 }
4080 self.plot_build_bom_lines()
4081 .iter()
4082 .all(|(id, _, need)| self.plot_build_stock_status(id, *need).1)
4083 }
4084
4085 pub fn currency_display(&self) -> String {
4086 crate::currency::currency_line(&self.inventory)
4087 }
4088
4089 pub fn in_shallow_water(&self) -> bool {
4091 let (px, py) = self.player_position();
4092 self.terrain_at(px, py)
4093 .is_some_and(|k| k == TerrainKindView::ShallowWater)
4094 }
4095
4096 pub fn near_liquid_fill_source(&self) -> bool {
4098 let (px, py) = self.player_position();
4099 const CELL: f32 = 1.0;
4100 let offsets = [
4101 (0.0, 0.0),
4102 (CELL, 0.0),
4103 (-CELL, 0.0),
4104 (0.0, CELL),
4105 (0.0, -CELL),
4106 ];
4107 for (dx, dy) in offsets {
4108 if matches!(
4109 self.terrain_at(px + dx, py + dy),
4110 Some(TerrainKindView::ShallowWater | TerrainKindView::DeepWater)
4111 ) {
4112 return true;
4113 }
4114 }
4115 self.buildings.iter().any(|b| {
4116 if !b.tags.iter().any(|t| t == "well") {
4117 return false;
4118 }
4119 let hw = b.width_m * 0.5;
4120 let hd = b.depth_m * 0.5;
4121 let nx = px.clamp(b.x - hw, b.x + hw);
4122 let ny = py.clamp(b.y - hd, b.y + hd);
4123 let dx = px - nx;
4124 let dy = py - ny;
4125 dx * dx + dy * dy <= INTERACTION_RADIUS_M * INTERACTION_RADIUS_M
4126 })
4127 }
4128
4129 pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
4130 self.terrain_zone_at(x, y).map(|z| z.kind)
4131 }
4132
4133 pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
4135 use std::cell::RefCell;
4136
4137 const CHUNK: i32 = 8;
4138 thread_local! {
4139 static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
4140 RefCell::new(None);
4141 }
4142
4143 let zones = &self.terrain_zones;
4144 if zones.is_empty() {
4145 return None;
4146 }
4147 if zones.len() <= 48 {
4148 return zones
4149 .iter()
4150 .enumerate()
4151 .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
4152 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
4153 .map(|(_, z)| z);
4154 }
4155
4156 let ptr = zones.as_ptr();
4157 let len = zones.len();
4158 INDEX.with(|cell| {
4159 let mut slot = cell.borrow_mut();
4160 let stale = match slot.as_ref() {
4161 Some((p, l, _)) => *p != ptr || *l != len,
4162 None => true,
4163 };
4164 if stale {
4165 let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
4166 std::collections::HashMap::new();
4167 for (zi, z) in zones.iter().enumerate() {
4168 let x0 = z.x0.min(z.x1).floor() as i32;
4169 let y0 = z.y0.min(z.y1).floor() as i32;
4170 let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
4171 let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
4172 let cx0 = x0.div_euclid(CHUNK);
4173 let cy0 = y0.div_euclid(CHUNK);
4174 let cx1 = x1.div_euclid(CHUNK);
4175 let cy1 = y1.div_euclid(CHUNK);
4176 for cy in cy0..=cy1 {
4177 for cx in cx0..=cx1 {
4178 chunks.entry((cx, cy)).or_default().push(zi);
4179 }
4180 }
4181 }
4182 *slot = Some((ptr, len, chunks));
4183 }
4184 let chunks = &slot.as_ref().expect("index").2;
4185 let cx = (x.floor() as i32).div_euclid(CHUNK);
4186 let cy = (y.floor() as i32).div_euclid(CHUNK);
4187 let mut best: Option<(usize, &TerrainZoneView)> = None;
4188 if let Some(list) = chunks.get(&(cx, cy)) {
4189 for &zi in list {
4190 let Some(z) = zones.get(zi) else { continue };
4191 if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
4192 continue;
4193 }
4194 best = match best {
4195 None => Some((zi, z)),
4196 Some((bi, bz)) => {
4197 if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
4198 Some((zi, z))
4199 } else {
4200 Some((bi, bz))
4201 }
4202 }
4203 };
4204 }
4205 }
4206 best.map(|(_, z)| z)
4207 })
4208 }
4209
4210 pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
4212 self.terrain_zone_at(x, y)
4213 .map(|z| z.elevation)
4214 .unwrap_or(0.0)
4215 }
4216
4217 pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
4219 const TOL: f32 = 0.35;
4220 let mut levels = vec![self.elevation_at(x, y)];
4221 for p in &self.z_platforms {
4222 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
4223 levels.push(p.z);
4224 }
4225 }
4226 levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
4227 levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
4228 levels
4229 }
4230
4231 pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
4232 const TOL: f32 = 0.35;
4233 self.walkable_levels_at(x, y)
4234 .iter()
4235 .any(|&l| (l - z).abs() <= TOL)
4236 }
4237
4238 pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
4239 let mut top = self.elevation_at(x, y);
4240 for p in &self.z_platforms {
4241 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
4242 top = top.max(p.z);
4243 }
4244 }
4245 top
4246 }
4247
4248 pub fn effective_inside_building(&self) -> Option<String> {
4250 self.player_entity().and_then(|p| p.inside_building.clone())
4251 }
4252
4253 pub fn placed_container_in_current_space(
4257 &self,
4258 c: &flatland_protocol::PlacedContainerView,
4259 ) -> bool {
4260 match (
4261 self.effective_inside_building().as_deref(),
4262 c.building_id.as_deref(),
4263 ) {
4264 (None, None) => true,
4265 (Some(a), Some(b)) => a == b,
4266 _ => false,
4267 }
4268 }
4269
4270 fn merge_stack_catalog_hints(&mut self, stacks: &[flatland_protocol::ItemStack]) {
4271 fn walk(
4272 stacks: &[flatland_protocol::ItemStack],
4273 hints: &mut std::collections::HashMap<String, InventoryHint>,
4274 ) {
4275 for stack in stacks {
4276 if stack.display_name.is_some()
4277 || stack.category.is_some()
4278 || stack.base_mass.is_some()
4279 || stack.base_volume.is_some()
4280 || stack.base_value_copper.is_some()
4281 {
4282 hints.insert(
4283 stack.template_id.clone(),
4284 InventoryHint {
4285 display_name: stack
4286 .display_name
4287 .clone()
4288 .unwrap_or_else(|| stack.template_id.clone()),
4289 category: stack.category.clone().unwrap_or_default(),
4290 base_mass: stack.base_mass,
4291 base_volume: stack.base_volume,
4292 capacity_volume: stack.capacity_volume,
4293 stackable: stack.stackable.unwrap_or(true),
4294 listable: stack.listable.unwrap_or_else(|| {
4295 category_default_listable(stack.category.as_deref().unwrap_or(""))
4296 }),
4297 base_value_copper: stack.base_value_copper,
4298 },
4299 );
4300 }
4301 walk(&stack.contents, hints);
4302 }
4303 }
4304 walk(stacks, &mut self.inventory_hints);
4305 }
4306
4307 pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
4308 self.inventory_stacks = stacks.to_vec();
4309 self.inventory.clear();
4310 self.inventory_hints.clear();
4311 fn walk(
4312 stacks: &[flatland_protocol::ItemStack],
4313 inventory: &mut std::collections::HashMap<String, u32>,
4314 hints: &mut std::collections::HashMap<String, InventoryHint>,
4315 ) {
4316 for stack in stacks {
4317 *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
4318 if stack.display_name.is_some()
4319 || stack.category.is_some()
4320 || stack.base_mass.is_some()
4321 || stack.base_volume.is_some()
4322 || stack.base_value_copper.is_some()
4323 {
4324 hints.insert(
4325 stack.template_id.clone(),
4326 InventoryHint {
4327 display_name: stack
4328 .display_name
4329 .clone()
4330 .unwrap_or_else(|| stack.template_id.clone()),
4331 category: stack.category.clone().unwrap_or_default(),
4332 base_mass: stack.base_mass,
4333 base_volume: stack.base_volume,
4334 capacity_volume: stack.capacity_volume,
4335 stackable: stack.stackable.unwrap_or(true),
4336 listable: stack.listable.unwrap_or_else(|| {
4337 category_default_listable(stack.category.as_deref().unwrap_or(""))
4338 }),
4339 base_value_copper: stack.base_value_copper,
4340 },
4341 );
4342 }
4343 walk(&stack.contents, inventory, hints);
4344 }
4345 }
4346 walk(stacks, &mut self.inventory, &mut self.inventory_hints);
4347 for item in self.worn.values() {
4349 walk(
4350 std::slice::from_ref(item),
4351 &mut self.inventory,
4352 &mut self.inventory_hints,
4353 );
4354 }
4355 }
4356
4357 fn sync_item_catalog(&mut self, entries: &[ItemCatalogEntryView]) {
4358 if entries.is_empty() {
4359 return;
4360 }
4361 self.item_catalog.clear();
4362 self.item_catalog.reserve(entries.len());
4363 for entry in entries {
4364 if entry.template_id.is_empty() {
4365 continue;
4366 }
4367 self.item_catalog
4368 .insert(entry.template_id.clone(), entry.clone());
4369 }
4370 }
4371
4372 fn remove_carried_instance(&mut self, instance_id: uuid::Uuid, quantity: Option<u32>) {
4376 fn take_from(
4377 stacks: &mut Vec<flatland_protocol::ItemStack>,
4378 instance_id: uuid::Uuid,
4379 qty: Option<u32>,
4380 ) -> bool {
4381 if let Some(i) = stacks
4382 .iter()
4383 .position(|s| s.item_instance_id == Some(instance_id))
4384 {
4385 let have = stacks[i].quantity;
4386 let take = qty.unwrap_or(have).min(have);
4387 if take >= have {
4388 stacks.remove(i);
4389 } else {
4390 stacks[i].quantity = have - take;
4391 }
4392 return true;
4393 }
4394 stacks
4395 .iter_mut()
4396 .any(|stack| take_from(&mut stack.contents, instance_id, qty))
4397 }
4398
4399 if take_from(&mut self.inventory_stacks, instance_id, quantity) {
4400 let stacks = self.inventory_stacks.clone();
4401 self.sync_inventory_from_stacks(&stacks);
4402 self.refresh_inventory_ui();
4403 return;
4404 }
4405 let slots: Vec<_> = self.worn.keys().copied().collect();
4406 for slot in slots {
4407 let Some(item) = self.worn.get_mut(&slot) else {
4408 continue;
4409 };
4410 if take_from(&mut item.contents, instance_id, quantity) {
4411 let stacks = self.inventory_stacks.clone();
4412 self.sync_inventory_from_stacks(&stacks);
4413 self.refresh_inventory_ui();
4414 return;
4415 }
4416 }
4417 }
4418
4419 pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
4422 if notice.message.starts_with("Gave ") {
4426 if notice.coins_delta != 0 {
4427 crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
4428 let stacks = self.inventory_stacks.clone();
4429 self.sync_inventory_from_stacks(&stacks);
4430 }
4431 self.record_shop_trade_notice(notice);
4432 return;
4433 }
4434 let subtract_items =
4435 notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
4436 for stack in ¬ice.inventory_delta {
4437 if stack.quantity == 0 {
4438 continue;
4439 }
4440 if subtract_items {
4441 crate::currency::drain_template_stacks(
4442 &mut self.inventory_stacks,
4443 &stack.template_id,
4444 stack.quantity,
4445 );
4446 continue;
4447 }
4448 let stackable = self
4449 .inventory_hints
4450 .get(&stack.template_id)
4451 .map(|h| h.stackable)
4452 .or(stack.stackable)
4453 .unwrap_or(true);
4454 if stackable {
4455 if let Some(existing) = self
4456 .inventory_stacks
4457 .iter_mut()
4458 .find(|s| s.template_id == stack.template_id)
4459 {
4460 existing.quantity = existing.quantity.saturating_add(stack.quantity);
4461 if stack.display_name.is_some() {
4462 existing.display_name = stack.display_name.clone();
4463 }
4464 if stack.category.is_some() {
4465 existing.category = stack.category.clone();
4466 }
4467 continue;
4468 }
4469 }
4470 self.inventory_stacks.push(stack.clone());
4471 }
4472 if notice.coins_delta != 0 {
4473 crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
4474 }
4475 if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
4476 let stacks = self.inventory_stacks.clone();
4477 self.sync_inventory_from_stacks(&stacks);
4478 }
4479 self.record_shop_trade_notice(notice);
4480 }
4481
4482 pub fn worn_rows(&self) -> Vec<InventoryRow> {
4487 let mut rows = Vec::new();
4488 for (slot, item) in &self.worn {
4489 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4490 rows.push(InventoryRow {
4491 depth: 0,
4492 stack: item.clone(),
4493 from: from.clone(),
4494 from_parent_instance_id: None,
4495 is_equip_shell: true,
4496 is_chest_shell: false,
4497 section: InventorySection::Worn,
4498 });
4499 for child in &item.contents {
4500 push_inventory_rows(
4501 &mut rows,
4502 1,
4503 child,
4504 &from,
4505 item.item_instance_id,
4506 InventorySection::Worn,
4507 );
4508 }
4509 }
4510 rows
4511 }
4512
4513 pub fn trade_presentable_stacks(&self) -> Vec<&flatland_protocol::ItemStack> {
4515 let equipped = self.hand_equipped_instance_ids();
4516 self.inventory_stacks
4517 .iter()
4518 .filter(|s| s.item_instance_id.is_some_and(|id| !equipped.contains(&id)))
4519 .collect()
4520 }
4521
4522 pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
4524 let equipped = self.hand_equipped_instance_ids();
4525 self.inventory_stacks
4526 .iter()
4527 .filter_map(|stack| {
4528 let item_instance_id = stack.item_instance_id?;
4529 if equipped.contains(&item_instance_id) {
4530 return None;
4531 }
4532 let label = stack
4533 .display_name
4534 .clone()
4535 .unwrap_or_else(|| stack.template_id.clone());
4536 let label = if stack.quantity > 1 {
4537 format!("{label} ×{}", stack.quantity)
4538 } else {
4539 label
4540 };
4541 Some(WorkerGiveOption {
4542 item_instance_id,
4543 label,
4544 quantity: stack.quantity,
4545 template_id: stack.template_id.clone(),
4546 })
4547 })
4548 .collect()
4549 }
4550
4551 pub fn teachable_blueprint_options(
4553 &self,
4554 worker: &flatland_protocol::HiredWorkerView,
4555 ) -> Vec<WorkerTeachOption> {
4556 let copper = crate::currency::copper_from_counts(&self.inventory);
4557 let mut options: Vec<WorkerTeachOption> = self
4558 .blueprints
4559 .iter()
4560 .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
4561 .map(|bp| {
4562 let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
4563 let cost = bp.worker_train_copper;
4564 WorkerTeachOption {
4565 blueprint_id: bp.id.clone(),
4566 label: if bp.label.is_empty() {
4567 bp.id.clone()
4568 } else {
4569 bp.label.clone()
4570 },
4571 cost_copper: cost,
4572 min_level,
4573 worker_level: worker.level,
4574 can_afford: copper >= cost,
4575 level_ok: worker.level >= min_level,
4576 }
4577 })
4578 .collect();
4579 options.sort_by(|a, b| a.label.cmp(&b.label));
4580 options
4581 }
4582
4583 pub fn person_rows(&self) -> Vec<InventoryRow> {
4586 self.person_rows_filtered("")
4587 }
4588
4589 pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4590 let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
4591 roots.sort_by(|a, b| {
4592 let ca = a
4593 .category
4594 .as_deref()
4595 .or_else(|| self.inventory_item_category(&a.template_id))
4596 .unwrap_or("");
4597 let cb = b
4598 .category
4599 .as_deref()
4600 .or_else(|| self.inventory_item_category(&b.template_id))
4601 .unwrap_or("");
4602 let ga = inventory_category_group(ca).1;
4603 let gb = inventory_category_group(cb).1;
4604 ga.cmp(&gb).then_with(|| {
4605 let na = a.display_name.as_deref().unwrap_or(a.template_id.as_str());
4606 let nb = b.display_name.as_deref().unwrap_or(b.template_id.as_str());
4607 na.cmp(nb)
4608 })
4609 });
4610 let mut rows = Vec::new();
4611 for stack in roots {
4612 push_inventory_rows_filtered(
4613 &mut rows,
4614 0,
4615 stack,
4616 &flatland_protocol::InventoryLocation::Root,
4617 None,
4618 InventorySection::Person,
4619 filter,
4620 );
4621 }
4622 rows
4623 }
4624
4625 pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4626 if filter.is_empty() {
4627 return self.worn_rows();
4628 }
4629 let mut rows = Vec::new();
4630 for (slot, item) in &self.worn {
4631 if !stack_matches_filter(item, filter) {
4632 continue;
4633 }
4634 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4635 let self_hit = {
4636 let f = filter.to_ascii_lowercase();
4637 let name = item
4638 .display_name
4639 .as_deref()
4640 .unwrap_or("")
4641 .to_ascii_lowercase();
4642 let tid = item.template_id.to_ascii_lowercase();
4643 name.contains(&f) || tid.contains(&f)
4644 };
4645 rows.push(InventoryRow {
4646 depth: 0,
4647 stack: item.clone(),
4648 from: from.clone(),
4649 from_parent_instance_id: None,
4650 is_equip_shell: true,
4651 is_chest_shell: false,
4652 section: InventorySection::Worn,
4653 });
4654 for child in &item.contents {
4655 if self_hit || stack_matches_filter(child, filter) {
4656 push_inventory_rows_filtered(
4657 &mut rows,
4658 1,
4659 child,
4660 &from,
4661 item.item_instance_id,
4662 InventorySection::Worn,
4663 if self_hit { "" } else { filter },
4664 );
4665 }
4666 }
4667 }
4668 rows
4669 }
4670
4671 pub fn carried_worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4675 let mut rows = Vec::new();
4676 for (slot, item) in &self.worn {
4677 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4678 for child in &item.contents {
4679 push_inventory_rows_filtered(
4680 &mut rows,
4681 0,
4682 child,
4683 &from,
4684 item.item_instance_id,
4685 InventorySection::Person,
4686 filter,
4687 );
4688 }
4689 }
4690 rows
4691 }
4692
4693 pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
4695 let mut rows = self.worn_rows();
4696 rows.extend(self.person_rows());
4697 rows.into_iter().map(|r| (r.depth, r.stack)).collect()
4698 }
4699
4700 pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
4704 let (px, py) = self.player_position();
4705 let mut list: Vec<NearbyContainer> = self
4706 .placed_containers
4707 .iter()
4708 .filter(|c| self.placed_container_in_current_space(c))
4709 .filter_map(|c| {
4710 let distance_m = (c.x - px).hypot(c.y - py);
4711 if distance_m > CONTAINER_RANGE_M {
4712 return None;
4713 }
4714 let mut rows = Vec::new();
4715 let from = flatland_protocol::InventoryLocation::Placed {
4716 container_id: c.id.clone(),
4717 };
4718 rows.push(InventoryRow {
4719 depth: 0,
4720 stack: flatland_protocol::ItemStack {
4721 template_id: c.template_id.clone(),
4722 quantity: 1,
4723 item_instance_id: c.item_instance_id,
4724 props: Default::default(),
4725 status_bindings: Vec::new(),
4726 contents: Vec::new(),
4727 display_name: Some(c.display_name.clone()),
4728 category: Some("container".into()),
4729 capacity_volume: c.capacity_volume,
4730 worker_lodging_capacity: c.worker_lodging_capacity,
4731 ..Default::default()
4732 },
4733 from: from.clone(),
4734 from_parent_instance_id: None,
4735 is_equip_shell: false,
4736 is_chest_shell: true,
4737 section: InventorySection::Nearby,
4738 });
4739 if c.accessible {
4740 for child in &c.contents {
4741 push_inventory_rows(
4742 &mut rows,
4743 1,
4744 child,
4745 &from,
4746 c.item_instance_id,
4747 InventorySection::Nearby,
4748 );
4749 }
4750 }
4751 Some(NearbyContainer {
4752 view: c.clone(),
4753 distance_m,
4754 rows,
4755 })
4756 })
4757 .collect();
4758 list.sort_by(|a, b| {
4759 a.distance_m
4760 .partial_cmp(&b.distance_m)
4761 .unwrap_or(std::cmp::Ordering::Equal)
4762 });
4763 list
4764 }
4765
4766 pub fn nearest_placed_container(
4768 &self,
4769 max_dist: f32,
4770 ) -> Option<flatland_protocol::PlacedContainerView> {
4771 let (px, py) = self.player_position();
4772 self.placed_containers
4773 .iter()
4774 .filter(|c| self.placed_container_in_current_space(c))
4775 .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
4776 .min_by(|a, b| {
4777 let da = (a.x - px).hypot(a.y - py);
4778 let db = (b.x - px).hypot(b.y - py);
4779 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
4780 })
4781 .cloned()
4782 }
4783
4784 pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
4787 let filter = self.inventory_filter.as_str();
4788 match self.inventory_tab {
4789 InventoryTab::OnPerson => {
4790 let mut rows = self.carried_worn_rows_filtered(filter);
4791 rows.extend(self.person_rows_filtered(filter));
4792 rows
4793 }
4794 InventoryTab::Nearby => {
4795 let mut rows = Vec::new();
4796 for nc in self.nearby_containers() {
4797 if filter.is_empty() {
4798 rows.extend(nc.rows);
4799 continue;
4800 }
4801 let shell = nc.rows.first().cloned();
4802 let contents: Vec<_> = nc
4803 .rows
4804 .iter()
4805 .skip(1)
4806 .filter(|r| stack_matches_filter(&r.stack, filter))
4807 .cloned()
4808 .collect();
4809 let shell_hit = shell
4810 .as_ref()
4811 .map(|s| stack_matches_filter(&s.stack, filter))
4812 .unwrap_or(false);
4813 if shell_hit || !contents.is_empty() {
4814 if let Some(s) = shell {
4815 rows.push(s);
4816 }
4817 if shell_hit {
4818 rows.extend(nc.rows.into_iter().skip(1));
4819 } else {
4820 rows.extend(contents);
4821 }
4822 }
4823 }
4824 rows
4825 }
4826 }
4827 }
4828
4829 pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
4830 self.inventory_selectable_rows()
4831 .into_iter()
4832 .nth(self.inventory_menu_index)
4833 }
4834
4835 fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
4836 let cat = self
4837 .inventory_item_category(&row.stack.template_id)
4838 .unwrap_or("");
4839 if cat == "key" {
4840 self.key_inventory_label(&row.stack)
4841 } else {
4842 row.stack
4843 .display_name
4844 .clone()
4845 .unwrap_or_else(|| row.stack.template_id.clone())
4846 }
4847 }
4848
4849 fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
4851 let bindings =
4852 format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
4853 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
4854 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
4855 let mode = Self::grant_mode(&row.stack);
4856 format!(" [grant {effect} · {mode} — e apply]")
4857 } else {
4858 String::new()
4859 };
4860 let qty = if row.stack.quantity > 1 {
4861 format!(" ×{}", row.stack.quantity)
4862 } else {
4863 String::new()
4864 };
4865 let worn_slot = if row.is_equip_shell {
4866 match row.from {
4867 flatland_protocol::InventoryLocation::Worn { slot } => {
4868 format!(" ({})", body_slot_label(slot))
4869 }
4870 _ => String::new(),
4871 }
4872 } else {
4873 String::new()
4874 };
4875 format!("{grant_hint}{bindings}{qty}{worn_slot}")
4876 }
4877
4878 fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
4879 (
4880 row.stack.template_id.clone(),
4881 self.inventory_row_base_label(row),
4882 self.inventory_row_visible_mod_signature(row),
4883 )
4884 }
4885
4886 fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
4888 let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
4889 for row in self.inventory_selectable_rows() {
4890 if row.stack.item_instance_id.is_none() {
4891 continue;
4892 }
4893 let key = self.inventory_row_instance_identity_key(&row);
4894 *counts.entry(key).or_default() += 1;
4895 }
4896 counts
4897 .into_iter()
4898 .filter(|(_, n)| *n > 1)
4899 .map(|(k, _)| k)
4900 .collect()
4901 }
4902
4903 fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
4904 let hex: String = id
4905 .as_simple()
4906 .to_string()
4907 .chars()
4908 .filter(|c| c.is_ascii_hexdigit())
4909 .collect();
4910 let short = if hex.len() >= 4 {
4911 &hex[hex.len() - 4..]
4912 } else {
4913 hex.as_str()
4914 };
4915 format!("Instance {id} (#{short})")
4916 }
4917
4918 pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
4920 let cat = self
4921 .inventory_item_category(&row.stack.template_id)
4922 .unwrap_or("");
4923 let label = self.inventory_row_base_label(row);
4924 let hint: String = if row.is_equip_shell {
4925 " [worn — Enter to unequip]".into()
4926 } else if row.is_chest_shell {
4927 let (locked, lodging_note) = match &row.from {
4928 flatland_protocol::InventoryLocation::Placed { container_id } => {
4929 let locked = self
4930 .placed_containers
4931 .iter()
4932 .find(|c| c.id == *container_id)
4933 .map(|c| c.locked)
4934 .unwrap_or(false);
4935 let lodging_note = self
4936 .lodging_occupancy_label(container_id)
4937 .map(|who| format!(" [lodging: {who}]"))
4938 .unwrap_or_default();
4939 (locked, lodging_note)
4940 }
4941 _ => (false, String::new()),
4942 };
4943 if locked {
4944 format!(" [locked — Enter pick up · l unlock]{lodging_note}")
4945 } else {
4946 format!(" [Enter pick up · l lock]{lodging_note}")
4947 }
4948 } else if cat == "key" {
4949 self.key_inventory_hint(&row.stack)
4950 } else {
4951 match cat {
4952 "weapon" => " [weapon]".into(),
4953 "container" => " [bag/chest/belt]".into(),
4954 "lodging" => " [worker lodging]".into(),
4955 "armor" => " [armor]".into(),
4956 _ => String::new(),
4957 }
4958 };
4959 let qty = if row.stack.quantity > 1 {
4960 format!(" ×{}", row.stack.quantity)
4961 } else {
4962 String::new()
4963 };
4964 let bindings =
4965 format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
4966 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
4967 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
4968 let mode = Self::grant_mode(&row.stack);
4969 format!(" [grant {effect} · {mode} — e apply]")
4970 } else {
4971 String::new()
4972 };
4973 let mass = self.stack_mass(&row.stack);
4974 let mass_kg = (mass >= 0.05).then_some(mass);
4975 let mass_str = mass_kg.map(|m| format!(" {m:.1} kg")).unwrap_or_default();
4976 let volume = self.container_volume_stats(row);
4977 let vol_str = self.container_volume_label(row);
4978
4979 let mut title = label.clone();
4980 title.push_str(&qty);
4981 if row.is_equip_shell {
4982 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
4983 title.push_str(&format!(" ({})", body_slot_label(slot)));
4984 }
4985 }
4986
4987 InventoryRowView {
4988 depth: row.depth,
4989 text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
4990 title: format!("{title}{grant_hint}{bindings}"),
4991 mass_kg,
4992 volume,
4993 instance_tooltip: None,
4994 }
4995 }
4996
4997 fn push_browser_item(
4998 &self,
4999 lines: &mut Vec<InventoryBrowserLine>,
5000 row: &InventoryRow,
5001 global_idx: &mut usize,
5002 target: usize,
5003 highlight: bool,
5004 ambiguous_instance_keys: &HashSet<(String, String, String)>,
5005 ) {
5006 let mut view = self.format_inventory_row(row);
5007 if let Some(id) = row.stack.item_instance_id {
5008 let key = self.inventory_row_instance_identity_key(row);
5009 if ambiguous_instance_keys.contains(&key) {
5010 view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
5011 }
5012 }
5013 lines.push(InventoryBrowserLine::Item {
5014 selectable_index: *global_idx,
5015 selected: highlight && *global_idx == target,
5016 depth: view.depth,
5017 text: view.text,
5018 title: view.title,
5019 mass_kg: view.mass_kg,
5020 volume: view.volume,
5021 instance_tooltip: view.instance_tooltip,
5022 });
5023 *global_idx += 1;
5024 }
5025
5026 pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
5029 let mut lines = Vec::new();
5030 let target = self.inventory_menu_index;
5031 let highlight = !self.show_move_picker && !self.show_grant_picker;
5032 let filter = self.inventory_filter.as_str();
5033 let mut global_idx = 0usize;
5034 let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
5035
5036 match self.inventory_tab {
5037 InventoryTab::OnPerson => {
5038 lines.push(InventoryBrowserLine::Section("— In carried bags —".into()));
5039 let carried = self.carried_worn_rows_filtered(filter);
5040 if carried.is_empty() {
5041 lines.push(InventoryBrowserLine::Hint(
5042 " (no items in carried bags)".into(),
5043 ));
5044 } else {
5045 for row in &carried {
5046 self.push_browser_item(
5047 &mut lines,
5048 row,
5049 &mut global_idx,
5050 target,
5051 highlight,
5052 &ambiguous_instance_keys,
5053 );
5054 }
5055 }
5056
5057 lines.push(InventoryBrowserLine::Blank);
5058 lines.push(InventoryBrowserLine::Section(
5059 "— On you (loose, not worn) —".into(),
5060 ));
5061 let person = self.person_rows_filtered(filter);
5062 if person.is_empty() {
5063 lines.push(InventoryBrowserLine::Hint(" (empty)".into()));
5064 } else {
5065 let mut last_group: Option<&'static str> = None;
5066 for row in &person {
5067 if row.depth == 0 {
5068 let cat = row
5069 .stack
5070 .category
5071 .as_deref()
5072 .or_else(|| self.inventory_item_category(&row.stack.template_id))
5073 .unwrap_or("");
5074 let (group, _) = inventory_category_group(cat);
5075 if last_group != Some(group) {
5076 lines.push(InventoryBrowserLine::SlotLabel(format!(" {group}")));
5077 last_group = Some(group);
5078 }
5079 }
5080 self.push_browser_item(
5081 &mut lines,
5082 row,
5083 &mut global_idx,
5084 target,
5085 highlight,
5086 &ambiguous_instance_keys,
5087 );
5088 }
5089 }
5090 }
5091 InventoryTab::Nearby => {
5092 let nearby = self.nearby_containers();
5093 if nearby.is_empty() {
5094 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
5095 lines.push(InventoryBrowserLine::Hint(
5096 " (none within reach — walk up to a chest)".into(),
5097 ));
5098 lines.push(InventoryBrowserLine::Hint(
5099 " Select an on-person item, then m / Enter → move into chest.".into(),
5100 ));
5101 } else {
5102 let mut any_visible = false;
5103 for nc in &nearby {
5104 let shell = nc.rows.first();
5105 let contents: Vec<&InventoryRow> = if filter.is_empty() {
5106 nc.rows.iter().skip(1).collect()
5107 } else {
5108 let shell_hit = shell
5109 .map(|s| {
5110 let f = filter.to_ascii_lowercase();
5111 let name = s
5112 .stack
5113 .display_name
5114 .as_deref()
5115 .unwrap_or("")
5116 .to_ascii_lowercase();
5117 let tid = s.stack.template_id.to_ascii_lowercase();
5118 name.contains(&f) || tid.contains(&f)
5119 })
5120 .unwrap_or(false);
5121 if shell_hit {
5122 nc.rows.iter().skip(1).collect()
5123 } else {
5124 nc.rows
5125 .iter()
5126 .skip(1)
5127 .filter(|r| stack_matches_filter(&r.stack, filter))
5128 .collect()
5129 }
5130 };
5131 let shell_visible = filter.is_empty()
5132 || shell
5133 .map(|s| stack_matches_filter(&s.stack, filter))
5134 .unwrap_or(false)
5135 || !contents.is_empty();
5136 if !shell_visible && shell.is_some() {
5137 continue;
5138 }
5139 any_visible = true;
5140 lines.push(InventoryBrowserLine::Blank);
5141 let lock_note = if nc.view.locked && nc.view.accessible {
5142 " unlocked with your key"
5143 } else if nc.view.locked {
5144 " locked"
5145 } else {
5146 ""
5147 };
5148 lines.push(InventoryBrowserLine::Section(format!(
5149 "— {} ({:.0}m away){lock_note} —",
5150 nc.view.display_name, nc.distance_m
5151 )));
5152 if !nc.view.accessible {
5153 lines.push(InventoryBrowserLine::Hint(
5154 " locked — need the matching key (l to try)".into(),
5155 ));
5156 } else if nc.rows.is_empty() {
5157 lines.push(InventoryBrowserLine::Hint(
5158 " (empty — switch to On person, select an item, m to move in)"
5159 .into(),
5160 ));
5161 } else if let Some(shell_row) = shell {
5162 self.push_browser_item(
5163 &mut lines,
5164 shell_row,
5165 &mut global_idx,
5166 target,
5167 highlight,
5168 &ambiguous_instance_keys,
5169 );
5170 for row in contents {
5171 self.push_browser_item(
5172 &mut lines,
5173 row,
5174 &mut global_idx,
5175 target,
5176 highlight,
5177 &ambiguous_instance_keys,
5178 );
5179 }
5180 }
5181 }
5182 if !any_visible {
5183 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
5184 lines.push(InventoryBrowserLine::Hint(
5185 " (no matching items — clear filter with Esc)".into(),
5186 ));
5187 }
5188 }
5189 }
5190 }
5191 lines
5192 }
5193
5194 pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
5196 let mut opts = Vec::new();
5197 opts.push(MoveOption::action(
5198 "Relocate…",
5199 MoveOptionKind::RelocatePlaced {
5200 container_id: container_id.to_string(),
5201 },
5202 ));
5203 opts.push(MoveOption::action(
5204 "On your person (loose)",
5205 MoveOptionKind::PickupPlaced {
5206 container_id: container_id.to_string(),
5207 nest_location: flatland_protocol::InventoryLocation::Root,
5208 nest_parent_instance_id: None,
5209 },
5210 ));
5211 for (slot, item) in &self.worn {
5212 if item.category.as_deref() != Some("container") {
5213 continue;
5214 }
5215 if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
5216 continue;
5217 }
5218 let Some(parent_id) = item.item_instance_id else {
5219 continue;
5220 };
5221 let shell_name = item
5222 .display_name
5223 .clone()
5224 .unwrap_or_else(|| item.template_id.clone());
5225 let nest_location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
5226 opts.push(MoveOption {
5227 label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
5228 kind: MoveOptionKind::PickupPlaced {
5229 container_id: container_id.to_string(),
5230 nest_location: nest_location.clone(),
5231 nest_parent_instance_id: Some(parent_id),
5232 },
5233 volume: self.destination_volume_stats(&nest_location, Some(parent_id)),
5234 });
5235 self.append_chest_pickup_nested(
5237 &mut opts,
5238 container_id,
5239 nest_location,
5240 item,
5241 &format!("in {shell_name}"),
5242 );
5243 }
5244 opts.push(MoveOption::action("Cancel", MoveOptionKind::Cancel));
5245 opts
5246 }
5247
5248 fn append_chest_pickup_nested(
5249 &self,
5250 opts: &mut Vec<MoveOption>,
5251 container_id: &str,
5252 location: flatland_protocol::InventoryLocation,
5253 parent: &flatland_protocol::ItemStack,
5254 context: &str,
5255 ) {
5256 for child in &parent.contents {
5257 if child.category.as_deref() != Some("container") {
5258 continue;
5259 }
5260 if !Self::is_volume_container_stack(child) {
5261 continue;
5262 }
5263 if child.world_placeable == Some(true) {
5265 continue;
5266 }
5267 let Some(child_id) = child.item_instance_id else {
5268 continue;
5269 };
5270 let name = child
5271 .display_name
5272 .clone()
5273 .unwrap_or_else(|| child.template_id.clone());
5274 opts.push(MoveOption {
5275 label: format!("{name} ({context})"),
5276 kind: MoveOptionKind::PickupPlaced {
5277 container_id: container_id.to_string(),
5278 nest_location: location.clone(),
5279 nest_parent_instance_id: Some(child_id),
5280 },
5281 volume: self.destination_volume_stats(&location, Some(child_id)),
5282 });
5283 self.append_chest_pickup_nested(
5284 opts,
5285 container_id,
5286 location.clone(),
5287 child,
5288 &format!("in {name}"),
5289 );
5290 }
5291 }
5292
5293 pub fn move_destinations_for(
5295 &self,
5296 from: &flatland_protocol::InventoryLocation,
5297 from_parent_instance_id: Option<uuid::Uuid>,
5298 moving_instance_id: Option<uuid::Uuid>,
5299 moving_template_id: &str,
5300 ) -> Vec<MoveOption> {
5301 let mut opts = Vec::new();
5302 if *from != flatland_protocol::InventoryLocation::Root {
5303 opts.push(MoveOption::action(
5304 "On your person (loose)",
5305 MoveOptionKind::Move {
5306 location: flatland_protocol::InventoryLocation::Root,
5307 parent_instance_id: None,
5308 },
5309 ));
5310 }
5311 for (slot, item) in &self.worn {
5312 if item.category.as_deref() != Some("container") {
5313 continue;
5314 }
5315 let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
5316 let shell_name = item
5317 .display_name
5318 .clone()
5319 .unwrap_or_else(|| item.template_id.clone());
5320
5321 if *slot != BodySlot::Waist
5323 && item.item_instance_id != moving_instance_id
5324 && Self::is_volume_container_stack(item)
5325 {
5326 self.push_move_destination(
5327 &mut opts,
5328 format!("{shell_name} (worn {})", body_slot_label(*slot)),
5329 location.clone(),
5330 item.item_instance_id,
5331 from,
5332 from_parent_instance_id,
5333 );
5334 }
5335
5336 if *slot == BodySlot::Waist
5338 && Self::attaches_to_belt_loop(moving_template_id)
5339 && item.item_instance_id != moving_instance_id
5340 {
5341 self.push_move_destination(
5342 &mut opts,
5343 format!("{shell_name} (belt loop)"),
5344 location.clone(),
5345 item.item_instance_id,
5346 from,
5347 from_parent_instance_id,
5348 );
5349 }
5350
5351 let context = if *slot == BodySlot::Waist {
5352 format!("on {shell_name}")
5353 } else {
5354 format!("in {shell_name}")
5355 };
5356 self.append_nested_container_destinations(
5357 &mut opts,
5358 location,
5359 item,
5360 &context,
5361 from,
5362 from_parent_instance_id,
5363 moving_instance_id,
5364 );
5365 }
5366 for nc in self.nearby_containers() {
5367 if !nc.view.accessible {
5368 continue;
5369 }
5370 let location = flatland_protocol::InventoryLocation::Placed {
5371 container_id: nc.view.id.clone(),
5372 };
5373 self.push_move_destination(
5374 &mut opts,
5375 format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
5376 location,
5377 nc.view.item_instance_id,
5378 from,
5379 from_parent_instance_id,
5380 );
5381 }
5382 let allow_drop = moving_instance_id
5383 .map(|id| !self.hand_equipped_instance_ids().contains(&id))
5384 .unwrap_or(true)
5385 && moving_instance_id
5386 .and_then(|id| self.stack_for_instance(id))
5387 .map(|stack| {
5388 !self.key_drop_blocked(&stack) && stack.template_id != PROPERTY_DEED_TEMPLATE
5389 })
5390 .unwrap_or(
5391 moving_template_id != KEY_TEMPLATE
5392 && moving_template_id != PROPERTY_DEED_TEMPLATE,
5393 );
5394 if allow_drop {
5395 opts.push(MoveOption::action(
5396 "Drop on the ground",
5397 MoveOptionKind::Drop,
5398 ));
5399 }
5400 opts.push(MoveOption::action("Cancel", MoveOptionKind::Cancel));
5401 opts
5402 }
5403
5404 fn is_same_container_dest(
5405 dest_location: &flatland_protocol::InventoryLocation,
5406 dest_parent: Option<uuid::Uuid>,
5407 from: &flatland_protocol::InventoryLocation,
5408 from_parent: Option<uuid::Uuid>,
5409 ) -> bool {
5410 dest_location == from && dest_parent == from_parent
5411 }
5412
5413 fn push_move_destination(
5414 &self,
5415 opts: &mut Vec<MoveOption>,
5416 label: String,
5417 location: flatland_protocol::InventoryLocation,
5418 parent_instance_id: Option<uuid::Uuid>,
5419 from: &flatland_protocol::InventoryLocation,
5420 from_parent_instance_id: Option<uuid::Uuid>,
5421 ) {
5422 if Self::is_same_container_dest(
5423 &location,
5424 parent_instance_id,
5425 from,
5426 from_parent_instance_id,
5427 ) {
5428 return;
5429 }
5430 let volume = self.destination_volume_stats(&location, parent_instance_id);
5431 opts.push(MoveOption {
5432 label,
5433 kind: MoveOptionKind::Move {
5434 location,
5435 parent_instance_id,
5436 },
5437 volume,
5438 });
5439 }
5440
5441 fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
5442 stack.capacity_volume.is_some_and(|c| c > 0.0)
5443 }
5444
5445 fn attaches_to_belt_loop(template_id: &str) -> bool {
5446 matches!(template_id, "leather_pouch" | "dimensional_pouch")
5447 }
5448
5449 fn append_nested_container_destinations(
5450 &self,
5451 opts: &mut Vec<MoveOption>,
5452 location: flatland_protocol::InventoryLocation,
5453 container: &flatland_protocol::ItemStack,
5454 context: &str,
5455 from: &flatland_protocol::InventoryLocation,
5456 from_parent_instance_id: Option<uuid::Uuid>,
5457 moving_instance_id: Option<uuid::Uuid>,
5458 ) {
5459 for child in &container.contents {
5460 if Self::is_volume_container_stack(child)
5461 && child.item_instance_id != moving_instance_id
5462 {
5463 let name = child
5464 .display_name
5465 .clone()
5466 .unwrap_or_else(|| child.template_id.clone());
5467 self.push_move_destination(
5468 opts,
5469 format!("{name} ({context})"),
5470 location.clone(),
5471 child.item_instance_id,
5472 from,
5473 from_parent_instance_id,
5474 );
5475 }
5476 let nested_context = format!(
5477 "in {}",
5478 child.display_name.as_deref().unwrap_or(&child.template_id)
5479 );
5480 self.append_nested_container_destinations(
5481 opts,
5482 location.clone(),
5483 child,
5484 &nested_context,
5485 from,
5486 from_parent_instance_id,
5487 moving_instance_id,
5488 );
5489 }
5490 }
5491
5492 fn clamp_inventory_indices(&mut self) {
5493 let n = self.inventory_selectable_rows().len();
5494 self.inventory_menu_index = if n == 0 {
5495 0
5496 } else {
5497 self.inventory_menu_index.min(n - 1)
5498 };
5499 if let Some(picker) = &self.move_picker {
5500 let pn = picker.options.len();
5501 self.move_picker_index = if pn == 0 {
5502 0
5503 } else {
5504 self.move_picker_index.min(pn - 1)
5505 };
5506 }
5507 }
5508
5509 fn sync_interior_map_context(&mut self) {
5514 if self.effective_inside_building().is_none() {
5515 self.interior_map = None;
5516 if let Some((platforms, transitions)) = self.z_bands_outdoor_backup.take() {
5517 self.z_platforms = platforms;
5518 self.z_transitions = transitions;
5519 }
5520 return;
5521 }
5522 self.sync_interior_z_bands();
5523 }
5524
5525 fn sync_interior_z_bands(&mut self) {
5527 if self.effective_inside_building().is_some() {
5528 if let Some(map) = &self.interior_map {
5529 if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
5530 if self.z_bands_outdoor_backup.is_none() {
5531 self.z_bands_outdoor_backup = Some((
5532 std::mem::take(&mut self.z_platforms),
5533 std::mem::take(&mut self.z_transitions),
5534 ));
5535 }
5536 self.z_platforms = map.z_platforms.clone();
5537 self.z_transitions = map.z_transitions.clone();
5538 }
5539 }
5540 }
5541 }
5542
5543 fn apply_snapshot_fields(
5544 &mut self,
5545 snapshot: &flatland_protocol::Snapshot,
5546 entity_id: EntityId,
5547 ) {
5548 self.tick = snapshot.tick;
5549 self.chunk_rev = snapshot.chunk_rev;
5550 self.content_rev = snapshot.content_rev;
5551 self.publish_rev = snapshot.publish_rev;
5552 self.resource_nodes = snapshot.resource_nodes.clone();
5553 self.replace_harvest_route_nodes(&snapshot.resource_nodes);
5554 self.ground_drops = snapshot.ground_drops.clone();
5555 self.placed_containers = snapshot.placed_containers.clone();
5556 self.world_x0 = snapshot.world_x0;
5557 self.world_y0 = snapshot.world_y0;
5558 self.world_width_m = snapshot.world_width_m;
5559 self.world_height_m = snapshot.world_height_m;
5560 self.world_clock = snapshot.world_clock;
5561 self.terrain_zones = snapshot.terrain_zones.clone();
5562 self.z_platforms = snapshot.z_platforms.clone();
5563 self.z_transitions = snapshot.z_transitions.clone();
5564 self.z_bands_outdoor_backup = None;
5566 self.buildings = snapshot.buildings.clone();
5567 self.doors = snapshot.doors.clone();
5568 self.interior_map = snapshot.interior_map.clone();
5569 self.npcs = snapshot.npcs.clone();
5570 self.blueprints = snapshot.blueprints.clone();
5571 self.building_materials = snapshot.building_materials.clone();
5572 self.sync_inventory_from_stacks(&snapshot.inventory);
5573 self.player = snapshot
5574 .entities
5575 .iter()
5576 .find(|e| e.id == entity_id)
5577 .cloned();
5578 self.entities = snapshot.entities.clone();
5579 self.quest_log = snapshot.quest_log.clone();
5580 self.apply_hired_workers(snapshot.hired_workers.clone());
5581 self.interactables = snapshot.interactables.clone();
5582 self.ledger = snapshot.ledger.clone();
5583 self.career = snapshot.career.clone();
5584 self.combat_fx = snapshot.combat_fx.clone();
5585 self.ground_hazards = snapshot.ground_hazards.clone();
5586 self.property_zones = snapshot.property_zones.clone();
5587 self.tax_zones = snapshot.tax_zones.clone();
5588 self.growth_zones = snapshot.growth_zones.clone();
5589 self.biome_zones = snapshot.biome_zones.clone();
5590 self.terrain_kind_nav = snapshot.terrain_kind_nav.clone();
5591 self.property_plots = snapshot.property_plots.clone();
5592 self.property_plot_settings = snapshot.property_plot_settings.clone();
5593 self.sync_item_catalog(&snapshot.item_catalog);
5594 if self.effective_inside_building().is_some() {
5597 self.z_bands_outdoor_backup = Some((Vec::new(), Vec::new()));
5598 }
5599 self.sync_interior_map_context();
5600 self.refresh_whisper_range();
5601 self.sync_gameplay_audio();
5602 }
5603
5604 fn replace_harvest_route_nodes(&mut self, incoming: &[flatland_protocol::ResourceNodeView]) {
5605 self.harvest_route_nodes = incoming
5606 .iter()
5607 .filter(|n| crate::is_harvest_route_node(n))
5608 .cloned()
5609 .collect();
5610 }
5611
5612 fn upsert_harvest_route_nodes(&mut self, incoming: &[flatland_protocol::ResourceNodeView]) {
5613 for node in incoming.iter().filter(|n| crate::is_harvest_route_node(n)) {
5614 if let Some(existing) = self
5615 .harvest_route_nodes
5616 .iter_mut()
5617 .find(|n| n.id == node.id)
5618 {
5619 *existing = node.clone();
5620 } else {
5621 self.harvest_route_nodes.push(node.clone());
5622 }
5623 }
5624 }
5625
5626 fn refresh_inventory_ui(&mut self) {
5630 if let Some(picker) = &self.move_picker {
5631 let instance_id = picker.item_instance_id;
5632 let still_exists = self
5633 .inventory_selectable_rows()
5634 .iter()
5635 .any(|r| r.stack.item_instance_id == Some(instance_id));
5636 if !still_exists {
5637 self.move_picker = None;
5638 self.show_move_picker = false;
5639 }
5640 }
5641 if let Some(picker) = &self.destroy_picker {
5642 let instance_id = picker.item_instance_id;
5643 let still_exists = self
5644 .inventory_selectable_rows()
5645 .iter()
5646 .any(|r| r.stack.item_instance_id == Some(instance_id));
5647 if !still_exists {
5648 self.destroy_picker = None;
5649 self.show_destroy_picker = false;
5650 self.destroy_confirm_pending = false;
5651 }
5652 }
5653 if let Some(picker) = &self.deconstruct_picker {
5654 let instance_id = picker.item_instance_id;
5655 let still_exists = self
5656 .inventory_selectable_rows()
5657 .iter()
5658 .any(|r| r.stack.item_instance_id == Some(instance_id));
5659 if !still_exists {
5660 self.deconstruct_picker = None;
5661 self.show_deconstruct_picker = false;
5662 self.deconstruct_confirm_pending = false;
5663 }
5664 }
5665 self.clamp_inventory_indices();
5666 }
5667
5668 fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
5674 let selected_id = self
5675 .hired_workers
5676 .get(self.workers_menu_index)
5677 .map(|w| w.instance_id.clone());
5678 let previous_worker_ids: HashSet<String> = self
5679 .hired_workers
5680 .iter()
5681 .map(|worker| worker.instance_id.clone())
5682 .collect();
5683 workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
5684 let now = Instant::now();
5685 let saw_new_worker = workers
5686 .iter()
5687 .any(|worker| !previous_worker_ids.contains(&worker.instance_id));
5688 for worker in &workers {
5689 let was_hit = self
5690 .hired_workers
5691 .iter()
5692 .find(|previous| previous.instance_id == worker.instance_id)
5693 .is_some_and(|previous| {
5694 matches!(worker.mode, flatland_protocol::WorkerModeView::Defender)
5695 && worker.vitals.health_pct + 0.01 < previous.vitals.health_pct
5696 });
5697 if was_hit {
5698 self.worker_health_ring_until
5699 .insert(worker.entity_id, now + WORKER_HEALTH_RING_HOLD);
5700 }
5701 }
5702 let worker_entity_ids: HashSet<EntityId> =
5703 workers.iter().map(|worker| worker.entity_id).collect();
5704 self.worker_health_ring_until
5705 .retain(|entity_id, _| worker_entity_ids.contains(entity_id));
5706 for w in &workers {
5707 let prev_err = self
5708 .hired_workers
5709 .iter()
5710 .find(|p| p.instance_id == w.instance_id)
5711 .and_then(|p| p.last_error.as_deref());
5712 let new_err = w.last_error.as_deref();
5713 if new_err != prev_err {
5714 if let Some(err) = new_err {
5715 if !worker_error_is_transient(err) {
5716 self.push_log(format!("Worker {}: {err}", w.label));
5717 }
5718 }
5719 }
5720 }
5721 let mut next_display = BTreeMap::new();
5722 let mut next_errors = BTreeMap::new();
5723 for w in &workers {
5724 let mut sticky = self
5725 .worker_step_display
5726 .remove(&w.instance_id)
5727 .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
5728 sticky.observe(&w.step_label, now);
5729 next_display.insert(w.instance_id.clone(), sticky);
5730
5731 let mut err_sticky = self
5732 .worker_error_display
5733 .remove(&w.instance_id)
5734 .unwrap_or_default();
5735 err_sticky.observe(w.last_error.as_deref(), now);
5736 if err_sticky.shown(now).is_some() {
5737 next_errors.insert(w.instance_id.clone(), err_sticky);
5738 }
5739 }
5740 self.worker_step_display = next_display;
5741 self.worker_error_display = next_errors;
5742 self.hired_workers = workers;
5743 if saw_new_worker {
5744 self.pending_worker_hire_since = None;
5745 }
5746 self.sync_worker_take_picker_from_hired();
5747 if let Some(id) = selected_id {
5748 if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
5749 self.workers_menu_index = idx;
5750 return;
5751 }
5752 }
5753 if self.workers_menu_index >= self.hired_workers.len() {
5754 self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
5755 }
5756 }
5757
5758 fn sync_worker_take_picker_from_hired(&mut self) {
5760 if !self.show_worker_take_picker {
5761 return;
5762 }
5763 let Some(picker) = self.worker_take_picker.clone() else {
5764 return;
5765 };
5766 let Some(worker) = self
5767 .hired_workers
5768 .iter()
5769 .find(|w| w.instance_id == picker.worker_instance_id)
5770 .cloned()
5771 else {
5772 self.show_worker_take_picker = false;
5773 self.worker_take_picker = None;
5774 self.worker_take_picker_index = 0;
5775 return;
5776 };
5777 let options: Vec<WorkerGiveOption> = worker
5778 .inventory
5779 .iter()
5780 .filter_map(|stack| {
5781 let item_instance_id = stack.item_instance_id?;
5782 let label = stack
5783 .display_name
5784 .clone()
5785 .unwrap_or_else(|| stack.template_id.clone());
5786 let label = if stack.quantity > 1 {
5787 format!("{label} ×{}", stack.quantity)
5788 } else {
5789 label
5790 };
5791 Some(WorkerGiveOption {
5792 item_instance_id,
5793 label,
5794 quantity: stack.quantity,
5795 template_id: stack.template_id.clone(),
5796 })
5797 })
5798 .collect();
5799 if options.is_empty() {
5800 self.show_worker_take_picker = false;
5801 self.worker_take_picker = None;
5802 self.worker_take_picker_index = 0;
5803 return;
5804 }
5805 let prev_id = picker
5806 .options
5807 .get(self.worker_take_picker_index)
5808 .map(|o| o.item_instance_id);
5809 let idx = prev_id
5810 .and_then(|id| options.iter().position(|o| o.item_instance_id == id))
5811 .unwrap_or(0)
5812 .min(options.len().saturating_sub(1));
5813 let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
5814 let quantity = picker.quantity.clamp(1, max_qty);
5815 self.worker_take_picker_index = idx;
5816 self.worker_take_picker = Some(WorkerTakePicker {
5817 worker_instance_id: picker.worker_instance_id,
5818 worker_label: picker.worker_label,
5819 options,
5820 quantity,
5821 });
5822 }
5823
5824 pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
5826 self.worker_step_display
5827 .get(worker_instance_id)
5828 .map(|s| s.shown.as_str())
5829 .or_else(|| {
5830 self.hired_workers
5831 .iter()
5832 .find(|w| w.instance_id == worker_instance_id)
5833 .map(|w| w.step_label.as_str())
5834 })
5835 .unwrap_or("")
5836 }
5837
5838 pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
5840 let now = Instant::now();
5841 self.worker_error_display
5842 .get(worker_instance_id)
5843 .and_then(|s| s.shown(now))
5844 .or_else(|| {
5845 self.hired_workers
5846 .iter()
5847 .find(|w| w.instance_id == worker_instance_id)
5848 .and_then(|w| w.last_error.as_deref())
5849 .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
5850 })
5851 .filter(|e| !worker_error_is_hud_noise(e))
5852 }
5853
5854 fn apply_combat_hud(&mut self, combat: &CombatHud) {
5855 self.in_combat = combat.in_combat;
5856 self.auto_attack = combat.auto_attack;
5857 self.combat_has_los = combat.has_los;
5858 self.attack_cd_ticks = combat.attack_cd_ticks;
5859 self.gcd_ticks = combat.gcd_ticks;
5860 self.weapon_ability_id = combat.ability_id.clone();
5861 self.mainhand_template_id = combat.mainhand_template_id.clone();
5862 self.mainhand_label = combat.mainhand_label.clone();
5863 self.mainhand_instance_id = combat.mainhand_instance_id;
5864 self.offhand_template_id = combat.offhand_template_id.clone();
5865 self.offhand_label = combat.offhand_label.clone();
5866 self.offhand_instance_id = combat.offhand_instance_id;
5867 self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
5868 1
5869 } else {
5870 combat.mainhand_hand_slots
5871 };
5872 self.defense = combat.defense.clone();
5873 self.worn = combat.worn.iter().cloned().collect();
5874 self.carry_mass = combat.carry_mass;
5875 self.carry_mass_max = combat.carry_mass_max;
5876 self.encumbrance = combat.encumbrance;
5877 self.move_speed_mps = combat.move_speed_mps;
5878 self.move_speed_mult = combat.move_speed_mult;
5879 self.cast_progress = combat.cast.clone();
5880 self.timed_channel = combat.timed_channel.clone();
5881 self.sync_craft_ready_pin();
5882 self.plot_build_offer = combat.plot_build.clone();
5883 self.ability_cooldowns = combat.ability_cooldowns.clone();
5884 self.blocking_active = combat.blocking_active;
5885 self.max_target_slots = combat.max_target_slots.max(1);
5886 self.combat_slots = combat.slots.clone();
5887 self.rotation_presets = combat.rotation_presets.clone();
5888 self.known_abilities = combat.known_abilities.clone();
5889 self.ability_meta = combat
5890 .ability_meta
5891 .iter()
5892 .cloned()
5893 .map(|meta| (meta.id.clone(), meta))
5894 .collect();
5895 self.ability_mastery = combat
5896 .ability_mastery
5897 .iter()
5898 .cloned()
5899 .map(|row| (row.ability_id.clone(), row))
5900 .collect();
5901 self.hotbar = combat.hotbar.clone();
5902 self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
5903 self.keychain_stacks = combat.keychain.clone();
5904 self.whisper_pouch_stacks = combat.whisper_pouch.clone();
5905 self.combat_target_detail = combat.target.clone();
5906 self.statuses = combat.statuses.clone();
5907 self.combat_target = combat.target_entity_id;
5908 if combat.progression_xp_base > 0.0 {
5909 self.progression_curve = Some(flatland_protocol::ProgressionCurve {
5910 baseline_display: combat.progression_baseline,
5911 xp_base: combat.progression_xp_base,
5912 xp_growth: combat.progression_xp_growth,
5913 });
5914 }
5915 if let Some(xp) = &combat.progression_xp {
5916 if let Some(player) = &mut self.player {
5917 player.progression_xp = Some(xp.clone());
5918 if let Some(attrs) = combat.attributes {
5919 player.attributes = Some(attrs);
5920 }
5921 if let Some(skills) = &combat.skills {
5922 player.skills = Some(skills.clone());
5923 }
5924 }
5925 }
5926 if let Some(label) = &combat.target_label {
5927 self.combat_target_label = Some(label.clone());
5928 } else if let Some(id) = combat.target_entity_id {
5929 self.combat_target_label = self
5930 .entities
5931 .iter()
5932 .find(|e| e.id == id)
5933 .map(|e| e.label.clone())
5934 .or_else(|| self.combat_target_label.clone());
5935 }
5936 self.refresh_inventory_ui();
5937 }
5938
5939 pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
5941 self.combat_slots
5942 .iter()
5943 .find(|s| s.slot_index == slot)
5944 .and_then(|s| s.target_entity_id)
5945 .or_else(|| if slot == 1 { self.combat_target } else { None })
5946 }
5947
5948 pub fn ability_allows_ground(&self, ability_id: &str) -> bool {
5950 self.ability_meta
5951 .get(ability_id)
5952 .map(|meta| matches!(meta.aim_mode.as_str(), "ground" | "either"))
5953 .unwrap_or(self.ground_target.is_some())
5956 }
5957
5958 pub fn ability_requires_ground(&self, ability_id: &str) -> bool {
5960 self.ability_meta
5961 .get(ability_id)
5962 .map(|meta| meta.aim_mode == "ground")
5963 .unwrap_or(false)
5964 }
5965
5966 pub fn ability_auto_rotation_eligible(&self, ability_id: &str) -> bool {
5969 self.ability_meta
5970 .get(ability_id)
5971 .map(|meta| meta.auto_rotation_eligible)
5972 .unwrap_or(true)
5973 }
5974
5975 pub fn set_ground_target(&mut self, x: f32, y: f32) {
5977 self.ground_target = Some((x, y, 0.0));
5978 }
5979
5980 pub fn clear_ground_target(&mut self) {
5982 self.ground_target = None;
5983 }
5984
5985 pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
5988 if !(1..=9).contains(&slot_1_to_9) {
5989 return None;
5990 }
5991 self.hotbar
5992 .get((slot_1_to_9 - 1) as usize)
5993 .and_then(|a| a.as_deref())
5994 .filter(|id| !id.is_empty())
5995 }
5996
5997 pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
5999 let binding = self.hotbar_ability(slot_1_to_9)?;
6000 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
6001 let name = self
6002 .inventory_hints
6003 .get(template_id)
6004 .map(|h| h.display_name.as_str())
6005 .unwrap_or(template_id);
6006 let qty = self.inventory.get(template_id).copied().unwrap_or(0);
6007 Some(format!("{name}×{qty}"))
6008 } else {
6009 Some(binding.to_string())
6010 }
6011 }
6012
6013 pub fn loadout_ability_choices(&self) -> Vec<String> {
6015 let mut out = self.known_abilities.clone();
6016 let weapon = self.weapon_ability_id.trim();
6017 if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
6018 out.push(weapon.to_string());
6019 }
6020 out
6021 }
6022
6023 pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
6025 let mut out = Vec::new();
6026 for ability in self.loadout_ability_choices() {
6027 let meta = if ability == self.weapon_ability_id {
6028 Some("weapon".into())
6029 } else {
6030 None
6031 };
6032 out.push(LoadoutHotbarChoice {
6033 binding: ability.clone(),
6034 label: ability,
6035 meta,
6036 });
6037 }
6038 let mut consumables: Vec<(String, String, u32)> = Vec::new();
6039 for stack in &self.inventory_stacks {
6040 if Self::stack_is_item_grant(stack) {
6041 continue;
6042 }
6043 if Self::stack_is_blueprint_scroll(stack) {
6044 continue;
6045 }
6046 if self.inventory_item_category(&stack.template_id) != Some("consumable")
6047 && !Self::stack_is_serving(stack)
6048 {
6049 continue;
6050 }
6051 let qty = stack.quantity.max(1);
6052 if let Some((_, _, existing)) = consumables
6053 .iter_mut()
6054 .find(|(id, _, _)| id == &stack.template_id)
6055 {
6056 *existing = existing.saturating_add(qty);
6057 } else {
6058 let label = stack
6059 .display_name
6060 .clone()
6061 .or_else(|| {
6062 self.inventory_hints
6063 .get(&stack.template_id)
6064 .map(|h| h.display_name.clone())
6065 })
6066 .unwrap_or_else(|| stack.template_id.clone());
6067 consumables.push((stack.template_id.clone(), label, qty));
6068 }
6069 }
6070 consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
6071 for (template_id, label, qty) in consumables {
6072 out.push(LoadoutHotbarChoice {
6073 binding: flatland_protocol::hotbar_consumable_binding(&template_id),
6074 label: format!("{label} ×{qty}"),
6075 meta: Some("use".into()),
6076 });
6077 }
6078 out
6079 }
6080
6081 pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
6083 self.combat_candidates()
6084 }
6085
6086 pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
6088 let (px, py) = self.player_position();
6089 let dist = |id: EntityId| {
6090 self.entities
6091 .iter()
6092 .find(|e| e.id == id)
6093 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
6094 .unwrap_or(f32::MAX)
6095 };
6096
6097 let mut allies = Vec::new();
6098 if let Some(me) = self.player.as_ref() {
6100 let alive = me
6101 .vitals
6102 .as_ref()
6103 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
6104 .unwrap_or(true);
6105 if alive {
6106 allies.push((self.entity_id, "Yourself".into()));
6107 }
6108 }
6109 for entity in &self.entities {
6110 if entity.id == self.entity_id {
6111 continue;
6112 }
6113 if entity.vitals.is_some() {
6114 let alive = entity
6115 .vitals
6116 .as_ref()
6117 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
6118 .unwrap_or(true);
6119 if alive {
6120 allies.push((entity.id, entity.label.clone()));
6121 }
6122 }
6123 }
6124 allies.sort_by(|(a, _), (b, _)| {
6125 if *a == self.entity_id {
6126 return std::cmp::Ordering::Less;
6127 }
6128 if *b == self.entity_id {
6129 return std::cmp::Ordering::Greater;
6130 }
6131 dist(*a)
6132 .partial_cmp(&dist(*b))
6133 .unwrap_or(std::cmp::Ordering::Equal)
6134 });
6135
6136 let mut monsters = self.combat_candidates();
6137 monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
6138 allies.into_iter().chain(monsters).collect()
6139 }
6140
6141 fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
6142 match slot_index {
6143 2 => self.t2_candidates(),
6144 _ => self.t1_candidates(),
6145 }
6146 }
6147
6148 pub fn pick_combat_target_at(
6150 &self,
6151 wx: f32,
6152 wy: f32,
6153 slot_index: u8,
6154 radius_m: f32,
6155 ) -> Option<(EntityId, String)> {
6156 let mut best: Option<(f32, EntityId, String)> = None;
6157 for (id, label) in self.candidates_for_slot(slot_index) {
6158 let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
6159 if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
6161 let d = distance(wx, wy, npc.x, npc.y);
6162 if d <= radius_m {
6163 best = match best {
6164 Some((bd, _, _)) if bd <= d => best,
6165 _ => Some((d, id, label)),
6166 };
6167 }
6168 }
6169 continue;
6170 };
6171 let d = distance(
6172 wx,
6173 wy,
6174 entity.transform.position.x,
6175 entity.transform.position.y,
6176 );
6177 if d <= radius_m {
6178 best = match best {
6179 Some((bd, _, _)) if bd <= d => best,
6180 _ => Some((d, id, label)),
6181 };
6182 }
6183 }
6184 best.map(|(_, id, label)| (id, label))
6185 }
6186
6187 pub(crate) fn restore_from_welcome(
6189 &mut self,
6190 session_id: SessionId,
6191 entity_id: EntityId,
6192 snapshot: &flatland_protocol::Snapshot,
6193 ) {
6194 self.clear_harvest_state();
6195 self.disconnect_reason = None;
6196 self.show_stats = false;
6197 self.show_craft_menu = false;
6198 self.show_shop_menu = false;
6199 self.shop_catalog = None;
6200 self.show_inventory_menu = false;
6201 self.session_id = session_id;
6202 self.entity_id = entity_id;
6203 self.connected = true;
6204 self.apply_snapshot_fields(snapshot, entity_id);
6205 if let Some(combat) = &snapshot.combat {
6206 self.apply_combat_hud(combat);
6207 let stacks = self.inventory_stacks.clone();
6208 self.sync_inventory_from_stacks(&stacks);
6209 }
6210 }
6211
6212 fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
6213 self.tick = delta.tick;
6214 self.world_clock = delta.world_clock;
6215
6216 if delta.entities.is_empty() {
6218 self.ground_drops = delta.ground_drops.clone();
6219 self.combat_fx = delta.combat_fx.clone();
6220 self.ground_hazards = delta.ground_hazards.clone();
6221 self.property_plots = delta.property_plots.clone();
6222 self.apply_terrain_overlays(&delta.terrain_overlays);
6223 if let Some(combat) = &delta.combat {
6224 self.apply_combat_hud(combat);
6225 let stacks = self.inventory_stacks.clone();
6226 self.sync_inventory_from_stacks(&stacks);
6227 }
6228 self.clamp_craft_menu_index();
6229 self.refresh_whisper_range();
6231 self.sync_gameplay_audio();
6232 return;
6233 }
6234 if !delta.buildings.is_empty() {
6235 self.buildings = delta.buildings.clone();
6236 }
6237 if !delta.blueprints.is_empty() {
6238 self.blueprints = delta.blueprints.clone();
6239 }
6240 if !delta.building_materials.is_empty() {
6241 self.building_materials = delta.building_materials.clone();
6242 }
6243 self.sync_inventory_from_stacks(&delta.inventory);
6244
6245 if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
6246 self.player = Some(updated.clone());
6247 }
6248 self.entities = delta.entities.clone();
6249 if self.player.is_none() {
6250 self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
6251 }
6252
6253 self.sync_interior_map_context();
6254
6255 if !delta.resource_nodes.is_empty() {
6259 self.resource_nodes = delta.resource_nodes.clone();
6260 self.upsert_harvest_route_nodes(&delta.resource_nodes);
6261 } else if delta.interior_map.is_some() || self.effective_inside_building().is_some() {
6262 self.resource_nodes = delta.resource_nodes.clone();
6263 }
6264 self.ground_drops = delta.ground_drops.clone();
6265 self.placed_containers = delta.placed_containers.clone();
6267 if !delta.doors.is_empty() {
6268 self.doors = delta.doors.clone();
6269 }
6270 if self.effective_inside_building().is_some() {
6271 if let Some(map) = &delta.interior_map {
6272 self.interior_map = Some(map.clone());
6273 }
6274 } else {
6275 self.interior_map = None;
6276 }
6277 self.sync_interior_z_bands();
6278 self.npcs = delta.npcs.clone();
6280 if !delta.quest_log.is_empty() {
6281 self.quest_log = delta.quest_log.clone();
6282 }
6283 self.apply_hired_workers(delta.hired_workers.clone());
6284 if !delta.interactables.is_empty() {
6285 self.interactables = delta.interactables.clone();
6286 }
6287 if delta.ledger.is_some() {
6288 self.ledger = delta.ledger.clone();
6289 }
6290 if delta.career.is_some() {
6291 self.career = delta.career.clone();
6292 }
6293 self.combat_fx = delta.combat_fx.clone();
6294 self.ground_hazards = delta.ground_hazards.clone();
6295 if !delta.property_plots.is_empty() {
6297 self.property_plots = delta.property_plots.clone();
6298 }
6299 self.apply_terrain_overlays(&delta.terrain_overlays);
6300 if let Some(combat) = &delta.combat {
6301 self.apply_combat_hud(combat);
6302 let stacks = self.inventory_stacks.clone();
6303 self.sync_inventory_from_stacks(&stacks);
6304 } else {
6305 self.refresh_inventory_ui();
6306 }
6307 self.clamp_craft_menu_index();
6308 self.refresh_whisper_range();
6309 self.sync_gameplay_audio();
6310 }
6311
6312 fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
6315 self.terrain_zones.retain(|z| !z.id.starts_with("rt:"));
6316 self.terrain_zones.extend(overlays.iter().cloned());
6317 }
6318
6319 fn refresh_whisper_range(&mut self) {
6322 let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
6323 return;
6324 };
6325 let (px, py) = self.player_position();
6326 let in_range = self.entities.iter().any(|e| {
6327 e.id == peer
6328 && distance(px, py, e.transform.position.x, e.transform.position.y)
6329 <= INTERACTION_RADIUS_M
6330 });
6331 if !in_range {
6332 self.social_chat.cancel_whisper_out_of_range();
6333 }
6334 }
6335
6336 pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
6338 let (px, py) = self.player_position();
6339 let mut out = Vec::new();
6340 for npc in &self.npcs {
6341 let Some(eid) = npc.entity_id else {
6342 continue;
6343 };
6344 let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
6345 let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
6346 if alive && has_hp {
6347 out.push((eid, npc.label.clone()));
6348 }
6349 }
6350 out.sort_by(|(a_id, a_label), (b_id, b_label)| {
6351 let dist = |id: EntityId| {
6352 self.entities
6353 .iter()
6354 .find(|e| e.id == id)
6355 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
6356 .unwrap_or(f32::MAX)
6357 };
6358 dist(*a_id)
6359 .partial_cmp(&dist(*b_id))
6360 .unwrap_or(std::cmp::Ordering::Equal)
6361 .then_with(|| a_label.cmp(b_label))
6362 .then_with(|| a_id.cmp(b_id))
6363 });
6364 out
6365 }
6366
6367 pub fn refresh_combat_target_label(&mut self) {
6368 let Some(id) = self.combat_target else {
6369 return;
6370 };
6371 if let Some((_, label)) = self
6372 .combat_candidates()
6373 .into_iter()
6374 .find(|(eid, _)| *eid == id)
6375 {
6376 self.combat_target_label = Some(label);
6377 } else if let Some(label) = self
6378 .entities
6379 .iter()
6380 .find(|e| e.id == id)
6381 .map(|e| e.label.clone())
6382 {
6383 self.combat_target_label = Some(label);
6384 }
6385 }
6386
6387 pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
6388 self.quest_log
6389 .iter()
6390 .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
6391 .collect()
6392 }
6393
6394 pub fn has_worker_lodging(&self) -> bool {
6396 self.free_worker_lodging_slots() > 0
6397 }
6398
6399 pub fn free_worker_lodging_slots(&self) -> i64 {
6401 let slots: u32 = self
6402 .placed_containers
6403 .iter()
6404 .filter(|c| match (self.character_id, c.owner_character_id) {
6405 (Some(me), Some(owner)) => me == owner,
6406 (Some(_), None) => false,
6407 (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
6408 })
6409 .map(|c| c.worker_lodging_capacity.unwrap_or(0))
6410 .sum();
6411 let used = self.hired_workers.len() as u32;
6412 slots as i64 - used as i64
6413 }
6414
6415 pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
6417 let mut names: Vec<String> = self
6418 .hired_workers
6419 .iter()
6420 .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
6421 .map(|w| w.label.clone())
6422 .collect();
6423 names.sort();
6424 names
6425 }
6426
6427 pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
6429 let is_lodging = self
6430 .placed_containers
6431 .iter()
6432 .find(|c| c.id == container_id)
6433 .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
6434 if !is_lodging {
6435 return None;
6436 }
6437 let names = self.lodging_occupant_labels(container_id);
6438 Some(if names.is_empty() {
6439 "vacant".into()
6440 } else {
6441 names.join(", ")
6442 })
6443 }
6444
6445 pub fn lodging_is_occupied(&self, container_id: &str) -> bool {
6447 matches!(
6448 self.lodging_occupancy_label(container_id),
6449 Some(label) if label != "vacant"
6450 )
6451 }
6452
6453 pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
6454 self.quest_log
6455 .iter()
6456 .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
6457 .or_else(|| {
6458 self.quest_log
6459 .iter()
6460 .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
6461 })
6462 }
6463
6464 pub fn nearby_lockable_door(&self) -> bool {
6466 let (px, py) = self.player_position();
6467 self.doors
6468 .iter()
6469 .any(|d| d.lock_id.is_some() && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M)
6470 }
6471
6472 pub fn nearby_open_player_door(&self) -> bool {
6474 if self.effective_inside_building().is_some() {
6475 return false;
6476 }
6477 let (px, py) = self.player_position();
6478 self.doors.iter().any(|d| {
6479 if !d.open || d.locked {
6480 return false;
6481 }
6482 let player_house = self
6483 .buildings
6484 .iter()
6485 .find(|b| b.id == d.building_id)
6486 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6487 player_house && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M
6488 })
6489 }
6490
6491 pub fn nearby_player_exit_door(&self) -> bool {
6493 let Some(bid) = self.effective_inside_building() else {
6494 return false;
6495 };
6496 let (px, py) = self.player_position();
6497 self.doors.iter().any(|d| {
6498 if d.building_id != bid || d.portal.is_none() {
6499 return false;
6500 }
6501 let player_house = self
6502 .buildings
6503 .iter()
6504 .find(|b| b.id == d.building_id)
6505 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6506 player_house && (d.x - px).hypot(d.y - py) <= 1.5
6507 })
6508 }
6509
6510 pub fn nearest_interact_target(&self) -> Option<String> {
6512 let (px, py) = self.player_position();
6513 let inside = self.effective_inside_building();
6514
6515 #[derive(Clone, Copy, PartialEq, Eq)]
6516 enum Kind {
6517 Player,
6518 Npc,
6519 HiredWorker,
6520 QuestBoard,
6521 ExitDoor,
6522 EnterDoor,
6523 }
6524
6525 fn kind_class(kind: Kind) -> u8 {
6526 match kind {
6527 Kind::EnterDoor => 0,
6528 Kind::QuestBoard => 1,
6529 Kind::Player | Kind::Npc => 2,
6530 Kind::ExitDoor => 3,
6531 Kind::HiredWorker => 4,
6532 }
6533 }
6534
6535 fn kind_priority(kind: Kind) -> u8 {
6536 match kind {
6537 Kind::EnterDoor => 0,
6538 Kind::QuestBoard => 1,
6539 Kind::Player | Kind::Npc => 2,
6540 Kind::ExitDoor => 3,
6541 Kind::HiredWorker => 4,
6542 }
6543 }
6544
6545 let mut best: Option<(f32, Kind, String)> = None;
6546
6547 let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
6548 if dist > max {
6549 return;
6550 }
6551 let replace = match best {
6552 None => true,
6553 Some((_, bk, _)) if kind_class(kind) < kind_class(bk) => true,
6554 Some((bd, bk, _)) if kind_class(kind) == kind_class(bk) && dist < bd - 0.05 => true,
6555 Some((bd, bk, _))
6556 if kind_class(kind) == kind_class(bk) && (dist - bd).abs() <= 0.05 =>
6557 {
6558 kind_priority(kind) < kind_priority(bk)
6559 }
6560 _ => false,
6561 };
6562 if replace {
6563 best = Some((dist, kind, id));
6564 }
6565 };
6566
6567 for npc in &self.npcs {
6568 consider(
6569 distance(px, py, npc.x, npc.y),
6570 INTERACTION_RADIUS_M,
6571 Kind::Npc,
6572 npc.id.clone(),
6573 );
6574 }
6575
6576 for worker in &self.hired_workers {
6577 if crate::use_world::hired_worker_yields_to_door(self, px, py, worker.x, worker.y) {
6578 continue;
6579 }
6580 consider(
6581 distance(px, py, worker.x, worker.y),
6582 INTERACTION_RADIUS_M,
6583 Kind::HiredWorker,
6584 worker.instance_id.clone(),
6585 );
6586 }
6587
6588 for entity in &self.entities {
6589 if entity.id == self.entity_id
6590 || entity.vitals.is_none()
6591 || entity.label.trim().is_empty()
6592 {
6593 continue;
6594 }
6595 if self.hired_workers.iter().any(|w| w.entity_id == entity.id) {
6597 continue;
6598 }
6599 consider(
6600 distance(
6601 px,
6602 py,
6603 entity.transform.position.x,
6604 entity.transform.position.y,
6605 ),
6606 INTERACTION_RADIUS_M,
6607 Kind::Player,
6608 entity.id.to_string(),
6609 );
6610 }
6611
6612 for door in &self.doors {
6613 if let Some(ref bid) = inside {
6614 if door.building_id != *bid {
6615 continue;
6616 }
6617 let is_exit = door.portal.is_some();
6618 let base = if is_exit {
6619 INTERACTION_RADIUS_M
6620 } else {
6621 DOOR_INTERACTION_RADIUS_M
6622 };
6623 let kind = if is_exit {
6624 Kind::ExitDoor
6625 } else {
6626 Kind::EnterDoor
6627 };
6628 consider(
6629 distance(px, py, door.x, door.y),
6630 crate::use_world::interact_door_range_m(self, door, base),
6631 kind,
6632 door.id.clone(),
6633 );
6634 continue;
6635 }
6636 consider(
6637 distance(px, py, door.x, door.y),
6638 crate::use_world::interact_door_range_m(self, door, DOOR_INTERACTION_RADIUS_M),
6639 Kind::EnterDoor,
6640 door.id.clone(),
6641 );
6642 }
6643
6644 if inside.is_none() {
6645 for inter in &self.interactables {
6646 if inter.kind == "quest_board" {
6647 consider(
6648 distance(px, py, inter.x, inter.y),
6649 QUEST_BOARD_INTERACTION_RADIUS_M,
6650 Kind::QuestBoard,
6651 inter.id.clone(),
6652 );
6653 }
6654 }
6655 }
6656
6657 best.map(|(_, _, id)| id)
6658 }
6659
6660 pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
6662 if self.effective_inside_building().is_some() {
6663 return None;
6664 }
6665 let (px, py) = self.player_position();
6666 self.interactables
6667 .iter()
6668 .filter(|i| i.kind == "quest_board")
6669 .map(|i| {
6670 let label = if i.label.is_empty() {
6671 "Quest board".to_string()
6672 } else {
6673 i.label.clone()
6674 };
6675 (label, distance(px, py, i.x, i.y))
6676 })
6677 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
6678 }
6679
6680 pub fn template_display_name(&self, template_id: &str) -> String {
6682 if let Some(name) = self
6683 .inventory_hints
6684 .get(template_id)
6685 .map(|h| h.display_name.clone())
6686 .filter(|n| !n.is_empty())
6687 {
6688 return name;
6689 }
6690 if let Some(entry) = self.item_catalog.get(template_id) {
6691 if !entry.display_name.trim().is_empty() {
6692 return entry.display_name.clone();
6693 }
6694 }
6695 humanize_template_id(template_id)
6696 }
6697
6698 pub fn worker_route_stop_summary(
6700 &self,
6701 stop: &crate::worker_route_editor::WorkerRouteStop,
6702 ) -> String {
6703 stop.summary_resolved(
6704 |id| {
6705 self.placed_containers
6706 .iter()
6707 .find(|c| c.id == id)
6708 .map(|c| c.display_name.clone())
6709 .filter(|n| !n.trim().is_empty())
6710 .unwrap_or_else(|| "storage".to_string())
6711 },
6712 |id| {
6713 self.npcs
6714 .iter()
6715 .find(|n| n.id == id)
6716 .map(|n| n.label.clone())
6717 .filter(|s| !s.trim().is_empty())
6718 .unwrap_or_else(|| "merchant".to_string())
6719 },
6720 |id| {
6721 self.harvest_route_nodes
6722 .iter()
6723 .chain(self.resource_nodes.iter())
6724 .find(|n| n.id == id)
6725 .map(resource_node_route_label)
6726 .unwrap_or_else(|| resource_node_route_label_parts(id, "", ""))
6727 },
6728 |id| plot_stop_label(&self.property_plots, *id),
6729 |t| self.template_display_name(t),
6730 )
6731 }
6732
6733 pub fn catalog_entry(&self, template_id: &str) -> Option<&ItemCatalogEntryView> {
6734 self.item_catalog.get(template_id)
6735 }
6736
6737 pub fn blueprint_item_label(&self, template_id: &str, display_name: &str) -> String {
6739 if !display_name.is_empty() {
6740 display_name.to_string()
6741 } else {
6742 self.template_display_name(template_id)
6743 }
6744 }
6745
6746 pub fn blueprint_output_label(&self, blueprint: &BlueprintView) -> String {
6747 self.blueprint_item_label(&blueprint.output, &blueprint.output_display_name)
6748 }
6749
6750 pub fn blueprint_ingredient_label(
6751 &self,
6752 input: &flatland_protocol::BlueprintIngredientView,
6753 ) -> String {
6754 self.blueprint_item_label(&input.template_id, &input.display_name)
6755 }
6756
6757 pub fn blueprint_tool_label(&self, tool: &flatland_protocol::ToolRequirementView) -> String {
6758 self.blueprint_item_label(&tool.item, &tool.display_name)
6759 }
6760
6761 pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
6763 use crate::worker_route_editor::{
6764 node_candidates, node_candidates_stable, route_editor_lodging_anchor,
6765 };
6766 let nodes = if self.harvest_route_nodes.is_empty() {
6767 &self.resource_nodes
6768 } else {
6769 &self.harvest_route_nodes
6770 };
6771 let lodging = self
6772 .worker_route_editor
6773 .as_ref()
6774 .and_then(|ed| ed.lodging_container_id.as_deref());
6775 match route_editor_lodging_anchor(lodging, &self.placed_containers) {
6776 Some((ax, ay)) => node_candidates(nodes, ax, ay),
6777 None => node_candidates_stable(nodes),
6778 }
6779 }
6780
6781 pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
6782 if dist_m.is_nan() {
6783 return "—".into();
6784 }
6785 let from_bed = self
6786 .worker_route_editor
6787 .as_ref()
6788 .and_then(|ed| ed.lodging_container_id.as_deref())
6789 .and_then(|id| {
6790 self.placed_containers
6791 .iter()
6792 .find(|c| c.id == id)
6793 .map(|c| c.display_name.clone())
6794 });
6795 match from_bed {
6796 Some(bed) => format!("{dist_m:.0}m from {bed}"),
6797 None => format!("{dist_m:.0}m"),
6798 }
6799 }
6800
6801 pub fn placed_container_public_label(
6803 &self,
6804 c: &flatland_protocol::PlacedContainerView,
6805 ) -> String {
6806 let is_owner = match (self.character_id, c.owner_character_id) {
6807 (Some(me), Some(owner)) => me == owner,
6808 _ => false,
6809 };
6810 if is_owner {
6811 c.display_name.clone()
6812 } else {
6813 self.template_display_name(&c.template_id)
6814 }
6815 }
6816
6817 pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
6819 let mut out = Vec::new();
6820 for stack in &self.inventory_stacks {
6821 if stack.template_id == KEY_TEMPLATE {
6822 out.push(KeychainEntry {
6823 stack: stack.clone(),
6824 stowed: false,
6825 });
6826 }
6827 }
6828 for stack in &self.keychain_stacks {
6829 if stack.template_id == KEY_TEMPLATE {
6830 out.push(KeychainEntry {
6831 stack: stack.clone(),
6832 stowed: true,
6833 });
6834 }
6835 }
6836 out
6837 }
6838
6839 pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
6841 if stack.template_id != KEY_TEMPLATE {
6842 return None;
6843 }
6844 if let Some(name) = stack
6845 .props
6846 .get(PROP_OPENS_CONTAINER_NAME)
6847 .filter(|n| !n.is_empty())
6848 {
6849 return Some(name.clone());
6850 }
6851 let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
6852 self.container_name_for_lock_id(opens)
6853 }
6854
6855 pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
6857 if stack.template_id == KEY_TEMPLATE {
6858 self.template_display_name(KEY_TEMPLATE)
6859 } else {
6860 stack
6861 .display_name
6862 .clone()
6863 .unwrap_or_else(|| stack.template_id.clone())
6864 }
6865 }
6866
6867 pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
6869 if stack.template_id != KEY_TEMPLATE {
6870 return String::new();
6871 }
6872 match self.key_pair_chest_label(stack) {
6873 Some(chest) if self.key_drop_blocked(stack) => {
6874 format!(" [key for {chest} — can't drop while locked]")
6875 }
6876 Some(chest) => format!(" [key for {chest}]"),
6877 None => " [key — unpaired]".into(),
6878 }
6879 }
6880
6881 pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
6883 for c in &self.placed_containers {
6884 if c.lock_id.as_deref() == Some(lock) {
6885 return Some(c.display_name.clone());
6886 }
6887 }
6888 Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
6889 self.worn
6890 .values()
6891 .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
6892 })
6893 }
6894
6895 pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
6897 if stack.template_id != KEY_TEMPLATE {
6898 return false;
6899 }
6900 let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
6901 return false;
6902 };
6903 for c in &self.placed_containers {
6904 if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
6905 return true;
6906 }
6907 }
6908 if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
6909 return true;
6910 }
6911 self.worn
6912 .values()
6913 .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
6914 }
6915
6916 pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
6918 stack.template_id == PROPERTY_DEED_TEMPLATE
6919 }
6920
6921 pub fn is_property_deed_template(template_id: &str) -> bool {
6922 template_id == PROPERTY_DEED_TEMPLATE
6923 }
6924
6925 pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
6926 stack
6927 .props
6928 .get("plot_id")
6929 .and_then(|s| uuid::Uuid::parse_str(s).ok())
6930 }
6931
6932 pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
6934 let (px, py) = self.player_position();
6935 let (cx, cy) = self.farm_plot_cell_under_player()?;
6936 let tx = cx as f32 + 0.5;
6937 let ty = cy as f32 + 0.5;
6938 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
6939 return None;
6940 }
6941 let kind = self.terrain_at(tx, ty).or_else(|| self.terrain_at(px, py));
6942 if kind == Some(TerrainKindView::Tilled) {
6943 return None;
6944 }
6945 if matches!(
6946 kind,
6947 Some(TerrainKindView::ShallowWater)
6948 | Some(TerrainKindView::DeepWater)
6949 | Some(TerrainKindView::Rock)
6950 ) {
6951 return None;
6952 }
6953 Some((tx, ty))
6954 }
6955
6956 fn container_name_in_stacks(
6957 stacks: &[flatland_protocol::ItemStack],
6958 lock: &str,
6959 ) -> Option<String> {
6960 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
6961 for s in stacks {
6962 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
6963 return Some(GameState::stack_container_label(s));
6964 }
6965 if let Some(name) = walk(&s.contents, lock) {
6966 return Some(name);
6967 }
6968 }
6969 None
6970 }
6971 walk(stacks, lock)
6972 }
6973
6974 fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
6975 stack
6976 .props
6977 .get(PROP_CUSTOM_NAME)
6978 .cloned()
6979 .or_else(|| stack.display_name.clone())
6980 .unwrap_or_else(|| stack.template_id.clone())
6981 }
6982
6983 fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
6984 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
6985 for s in stacks {
6986 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
6987 return true;
6988 }
6989 if walk(&s.contents, lock) {
6990 return true;
6991 }
6992 }
6993 false
6994 }
6995 walk(stacks, lock)
6996 }
6997
6998 fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
6999 if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
7000 return Some(stack.clone());
7001 }
7002 for worn in self.worn.values() {
7003 if worn.item_instance_id == Some(instance_id) {
7004 return Some(worn.clone());
7005 }
7006 if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
7007 return Some(stack.clone());
7008 }
7009 }
7010 None
7011 }
7012
7013 pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
7015 self.property_zones
7016 .iter()
7017 .enumerate()
7018 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
7019 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
7020 .map(|(_, z)| z)
7021 }
7022
7023 pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
7025 self.tax_zones
7026 .iter()
7027 .enumerate()
7028 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
7029 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
7030 .map(|(_, z)| z)
7031 }
7032
7033 pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
7035 let mut max_bps = 0u32;
7036 let mut y = y0 + 0.5;
7037 while y < y1 {
7038 let mut x = x0 + 0.5;
7039 while x < x1 {
7040 if let Some(tz) = self.tax_zone_at(x, y) {
7041 max_bps = max_bps.max(tz.rate_bps);
7042 }
7043 x += 1.0;
7044 }
7045 y += 1.0;
7046 }
7047 max_bps
7048 }
7049
7050 pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
7052 let mode = self.claim_mode.as_ref()?;
7053 let w = mode.width_m.max(1) as f32;
7054 let h = mode.height_m.max(1) as f32;
7055 Some((
7056 mode.anchor_x,
7057 mode.anchor_y,
7058 mode.anchor_x + w,
7059 mode.anchor_y + h,
7060 ))
7061 }
7062
7063 pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
7065 let mode = self.relocate_mode.as_ref()?;
7066 let x0 = mode.cursor_x.floor();
7067 let y0 = mode.cursor_y.floor();
7068 Some((x0, y0, x0 + 1.0, y0 + 1.0))
7069 }
7070
7071 pub fn claim_quote(&self) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
7074 let mode = self.claim_mode.as_ref()?;
7075 let zone = self.property_zones.iter().find(|z| z.id == mode.zone_id)?;
7076 let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
7077 let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
7078 let zone_area = zone_view_area_m2(zone).max(1.0);
7079 let area_frac = (area / zone_area).clamp(0.0, 1.0);
7080 let weight = self
7081 .property_plot_settings
7082 .as_ref()
7083 .map(|s| s.tax_premium_weight)
7084 .unwrap_or(0.5)
7085 .max(0.0);
7086 let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
7087 let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
7088 let purchase = ((zone.crown_price_copper as f64) * (area_frac as f64) * (premium as f64))
7089 .ceil()
7090 .max(0.0) as u64;
7091 let upkeep = if zone.upkeep_copper_per_day == 0 {
7092 0
7093 } else {
7094 ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
7095 .ceil()
7096 .max(1.0) as u64
7097 };
7098 let copper = crate::currency::copper_from_counts(&self.inventory);
7099 let can_afford = copper >= purchase;
7100 let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
7101 Some((purchase, upkeep, area, premium, can_afford, valid, reason))
7102 }
7103
7104 fn validate_claim_footprint(
7105 &self,
7106 zone: &flatland_protocol::PropertyZoneView,
7107 x0: f32,
7108 y0: f32,
7109 x1: f32,
7110 y1: f32,
7111 area: f32,
7112 ) -> (bool, String) {
7113 let min_area = self
7114 .property_plot_settings
7115 .as_ref()
7116 .map(|s| s.min_plot_area_m2)
7117 .unwrap_or(4.0);
7118 if area + f32::EPSILON < min_area {
7119 return (false, "plot too small".into());
7120 }
7121 if zone.max_area_m2.is_some_and(|m| area > m) {
7122 return (false, "plot exceeds max area".into());
7123 }
7124 if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
7125 return (false, "plot must lie inside the property zone".into());
7126 }
7127 if self
7128 .property_plots
7129 .iter()
7130 .any(|p| rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1))
7131 {
7132 return (false, "plot overlaps an existing claim".into());
7133 }
7134 (true, String::new())
7135 }
7136
7137 pub fn free_property_zone_under_player(&self) -> Option<&flatland_protocol::PropertyZoneView> {
7139 let (px, py) = self.player_position();
7140 let zone = self.property_zone_at(px, py)?;
7141 if self.property_plots.iter().any(|p| point_in_plot(px, py, p)) {
7142 return None;
7143 }
7144 Some(zone)
7145 }
7146
7147 pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
7149 let (px, py) = self.player_position();
7150 self.property_plots
7151 .iter()
7152 .find(|p| p.is_mine && point_in_plot(px, py, p))
7153 }
7154
7155 pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
7157 let (px, py) = self.player_position();
7158 self.property_plots
7159 .iter()
7160 .find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
7161 }
7162
7163 pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
7165 if self.farmable_plot_under_player().is_none() {
7166 return None;
7167 }
7168 let (px, py) = self.player_position();
7169 Some((px.floor() as i32, py.floor() as i32))
7170 }
7171
7172 fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
7173 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
7174 self.resource_nodes.iter().any(|n| {
7175 let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
7176 ncx == cx && ncy == cy || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
7177 })
7178 }
7179
7180 fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
7181 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
7182 let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
7183 || self
7184 .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
7185 .is_some_and(|z| z.kind == TerrainKindView::Tilled);
7186 if !tilled {
7187 return false;
7188 }
7189 !self.resource_node_occupies_farm_cell(cx, cy)
7190 }
7191
7192 pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
7194 let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
7195 return false;
7196 };
7197 self.free_tilled_plant_slot_at(cx, cy)
7198 }
7199
7200 pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
7202 let (px, py) = self.player_position();
7203 for dy in -2..=2 {
7204 for dx in -2..=2 {
7205 let cx = px.floor() as i32 + dx;
7206 let cy = py.floor() as i32 + dy;
7207 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
7208 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
7209 continue;
7210 }
7211 if self.free_tilled_plant_slot_at(cx, cy) {
7212 return true;
7213 }
7214 }
7215 }
7216 false
7217 }
7218
7219 fn stack_is_farm_seed(&self, stack: &flatland_protocol::ItemStack) -> bool {
7220 if stack.quantity == 0 {
7221 return false;
7222 }
7223 if stack.props.contains_key("seed_for") {
7224 return true;
7225 }
7226 if let Some(entry) = self.item_catalog.get(&stack.template_id) {
7227 if entry.is_farm_seed() {
7228 return true;
7229 }
7230 }
7231 stack.template_id.ends_with("_seed")
7232 }
7233
7234 pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
7236 let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
7237 fn walk(
7238 stacks: &[flatland_protocol::ItemStack],
7239 state: &GameState,
7240 counts: &mut std::collections::HashMap<String, u32>,
7241 ) {
7242 for s in stacks {
7243 if state.stack_is_farm_seed(s) {
7244 *counts.entry(s.template_id.clone()).or_default() += s.quantity;
7245 }
7246 walk(&s.contents, state, counts);
7247 }
7248 }
7249 walk(&self.inventory_stacks, self, &mut counts);
7250 for worn in self.worn.values() {
7251 walk(std::slice::from_ref(worn), self, &mut counts);
7252 }
7253 let mut out: Vec<_> = counts
7254 .into_iter()
7255 .map(|(template_id, quantity)| {
7256 let label = self.template_display_name(&template_id);
7257 (template_id, quantity, label)
7258 })
7259 .collect();
7260 out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
7261 out
7262 }
7263
7264 pub fn first_farm_seed_template(&self) -> Option<String> {
7266 self.farm_seed_entries()
7267 .into_iter()
7268 .next()
7269 .map(|(id, _, _)| id)
7270 }
7271
7272 pub fn clamp_plant_menu(&mut self) {
7273 let n = self.farm_seed_entries().len();
7274 if n == 0 {
7275 self.plant_menu_index = 0;
7276 self.plant_quantity = 1;
7277 return;
7278 }
7279 self.plant_menu_index = self.plant_menu_index.min(n - 1);
7280 let max_qty = self
7281 .farm_seed_entries()
7282 .get(self.plant_menu_index)
7283 .map(|(_, q, _)| *q)
7284 .unwrap_or(1)
7285 .max(1);
7286 self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
7287 }
7288
7289 pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
7290 let entries = self.farm_seed_entries();
7291 let (id, max, label) = entries.get(self.plant_menu_index)?;
7292 let qty = self.plant_quantity.min(*max).max(1);
7293 Some((id.clone(), qty, label.clone()))
7294 }
7295
7296 pub fn location_context_lines(&self) -> Vec<ContextLine> {
7298 let (px, py) = self.player_position();
7299 let inside = self.effective_inside_building();
7300 let mut lines = Vec::new();
7301
7302 if let Some(kind) = self.terrain_at(px, py) {
7303 lines.push(ContextLine {
7304 on_top: true,
7305 text: format!("Terrain: {}", terrain_kind_label(kind)),
7306 });
7307 }
7308
7309 if let Some(id) = inside.as_ref() {
7310 if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
7311 lines.push(ContextLine {
7312 on_top: true,
7313 text: format!("Inside: {}", b.label),
7314 });
7315 }
7316 }
7317
7318 let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
7319
7320 for node in &self.resource_nodes {
7321 if node.id.starts_with("preview:") {
7322 continue;
7323 }
7324 let dist = distance(px, py, node.x, node.y);
7325 if dist > NEARBY_SCAN_M {
7326 continue;
7327 }
7328 let on_top = dist <= ON_TOP_RADIUS_M;
7329 let prefix = if on_top { "On" } else { "Near" };
7330 let name = resource_node_near_display_label(&node.label);
7331 let action = resource_node_near_action_suffix(node);
7332 nearby.push((
7333 dist,
7334 ContextLine {
7335 on_top,
7336 text: format!("{prefix}: {name} ({dist:.1}m){action}"),
7337 },
7338 ));
7339 }
7340
7341 for drop in &self.ground_drops {
7342 let dist = distance(px, py, drop.x, drop.y);
7343 if dist > INTERACTION_RADIUS_M {
7344 continue;
7345 }
7346 let on_top = dist <= ON_TOP_RADIUS_M;
7347 let name = self.template_display_name(&drop.template_id);
7348 let prefix = if on_top { "On" } else { "Near" };
7349 let qty = if drop.quantity > 1 {
7350 format!(" ×{}", drop.quantity)
7351 } else {
7352 String::new()
7353 };
7354 nearby.push((
7355 dist,
7356 ContextLine {
7357 on_top,
7358 text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
7359 },
7360 ));
7361 }
7362
7363 for c in &self.placed_containers {
7364 if !self.placed_container_in_current_space(c) {
7365 continue;
7366 }
7367 let dist = distance(px, py, c.x, c.y);
7368 if dist > CONTAINER_RANGE_M {
7369 continue;
7370 }
7371 let on_top = dist <= ON_TOP_RADIUS_M;
7372 let name = self.placed_container_public_label(c);
7373 let lock = if c.locked { " [locked]" } else { "" };
7374 let prefix = if on_top { "On" } else { "Near" };
7375 nearby.push((
7376 dist,
7377 ContextLine {
7378 on_top,
7379 text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
7380 },
7381 ));
7382 }
7383
7384 for npc in &self.npcs {
7385 let dist = distance(px, py, npc.x, npc.y);
7386 if dist > NEARBY_SCAN_M {
7387 continue;
7388 }
7389 let on_top = dist <= ON_TOP_RADIUS_M;
7390 let prefix = if on_top { "On" } else { "Near" };
7391 nearby.push((
7392 dist,
7393 ContextLine {
7394 on_top,
7395 text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
7396 },
7397 ));
7398 }
7399
7400 for door in &self.doors {
7401 let dist = distance(px, py, door.x, door.y);
7402 if dist > DOOR_INTERACTION_RADIUS_M {
7403 continue;
7404 }
7405 let building = self
7406 .buildings
7407 .iter()
7408 .find(|b| b.id == door.building_id)
7409 .map(|b| b.label.as_str())
7410 .unwrap_or(door.building_id.as_str());
7411 let player_house = self
7412 .buildings
7413 .iter()
7414 .find(|b| b.id == door.building_id)
7415 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
7416 let action = if inside.is_some() && door.portal.is_some() {
7417 if player_house {
7418 if door.locked {
7419 "locked — l unlock · Enter exit".to_string()
7420 } else if door.open {
7421 "close · Enter exit · l lock".to_string()
7422 } else {
7423 "open · Enter exit · l lock".to_string()
7424 }
7425 } else {
7426 "exit".to_string()
7427 }
7428 } else if player_house {
7429 if door.locked {
7430 "locked — l unlock".to_string()
7431 } else if door.open {
7432 "close · Enter go inside · l lock".to_string()
7433 } else {
7434 "open · l lock".to_string()
7435 }
7436 } else {
7437 "enter".to_string()
7438 };
7439 nearby.push((
7440 dist,
7441 ContextLine {
7442 on_top: dist <= ON_TOP_RADIUS_M,
7443 text: format!("{building} door ({dist:.1}m) — f {action}"),
7444 },
7445 ));
7446 }
7447
7448 if inside.is_none() {
7449 for inter in &self.interactables {
7450 if inter.kind != "quest_board" {
7451 continue;
7452 }
7453 let dist = distance(px, py, inter.x, inter.y);
7454 if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
7455 continue;
7456 }
7457 let on_top = dist <= ON_TOP_RADIUS_M;
7458 let prefix = if on_top { "On" } else { "Near" };
7459 let label = if inter.label.is_empty() {
7460 "Quest board".to_string()
7461 } else {
7462 inter.label.clone()
7463 };
7464 nearby.push((
7465 dist,
7466 ContextLine {
7467 on_top,
7468 text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
7469 },
7470 ));
7471 }
7472 }
7473
7474 if self.near_liquid_fill_source() {
7475 let on_water = matches!(
7476 self.terrain_at(px, py),
7477 Some(TerrainKindView::ShallowWater | TerrainKindView::DeepWater)
7478 );
7479 let well = self.buildings.iter().find(|b| {
7480 b.tags.iter().any(|t| t == "well") && {
7481 let hw = b.width_m * 0.5;
7482 let hd = b.depth_m * 0.5;
7483 let nx = px.clamp(b.x - hw, b.x + hw);
7484 let ny = py.clamp(b.y - hd, b.y + hd);
7485 let dx = px - nx;
7486 let dy = py - ny;
7487 dx * dx + dy * dy <= INTERACTION_RADIUS_M * INTERACTION_RADIUS_M
7488 }
7489 });
7490 if let Some(well) = well {
7491 let name = if well.label.trim().is_empty() {
7492 "Well"
7493 } else {
7494 well.label.as_str()
7495 };
7496 nearby.push((
7497 0.0,
7498 ContextLine {
7499 on_top: true,
7500 text: format!("{name} — Use a vessel from inventory to fill"),
7501 },
7502 ));
7503 } else if on_water {
7504 if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
7505 line.text.push_str(" — Use a vessel from inventory to fill");
7506 }
7507 } else {
7508 nearby.push((
7509 0.0,
7510 ContextLine {
7511 on_top: true,
7512 text: "Water nearby — Use a vessel from inventory to fill".into(),
7513 },
7514 ));
7515 }
7516 }
7517
7518 if self.claim_mode.is_some() {
7519 nearby.push((
7520 0.0,
7521 ContextLine {
7522 on_top: true,
7523 text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
7524 .into(),
7525 },
7526 ));
7527 } else if let Some(plot) = self.my_plot_under_player() {
7528 let name = plot_public_label(plot);
7529 let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
7530 format!("{name} — f again to sell to crown")
7531 } else {
7532 format!(
7533 "{name} — Shift+c till · p plant · f harvest · B build · l door lock · o farm access · Shift+n rename"
7534 )
7535 };
7536 nearby.push((
7537 0.0,
7538 ContextLine {
7539 on_top: true,
7540 text: prompt,
7541 },
7542 ));
7543 } else if let Some(plot) = self.farmable_plot_under_player() {
7544 let name = plot_public_label(plot);
7545 let disc = if plot.farm_public {
7546 plot.public_tax_discount_bps / 100
7547 } else {
7548 plot.farm_allow
7549 .iter()
7550 .find(|g| Some(g.character_id) == self.character_id)
7551 .map(|g| g.tax_discount_bps / 100)
7552 .unwrap_or(0)
7553 };
7554 nearby.push((
7555 0.0,
7556 ContextLine {
7557 on_top: true,
7558 text: format!(
7559 "{name} (farming · tax −{disc}%) — Shift+c till · p plant · f harvest"
7560 ),
7561 },
7562 ));
7563 } else if let Some(zone) = self.free_property_zone_under_player() {
7564 let label = zone
7565 .label
7566 .as_deref()
7567 .filter(|s| !s.trim().is_empty())
7568 .unwrap_or(zone.id.as_str());
7569 nearby.push((
7570 0.0,
7571 ContextLine {
7572 on_top: true,
7573 text: format!("Claimable land: {label} — k buy plot"),
7574 },
7575 ));
7576 }
7577
7578 for entity in &self.entities {
7579 if entity.id == self.entity_id {
7580 continue;
7581 }
7582 let dist = distance(
7583 px,
7584 py,
7585 entity.transform.position.x,
7586 entity.transform.position.y,
7587 );
7588 if dist > NEARBY_SCAN_M {
7589 continue;
7590 }
7591 let label = if entity.label.is_empty() {
7592 format!("entity {}", entity.id)
7593 } else {
7594 entity.label.clone()
7595 };
7596 nearby.push((
7597 dist,
7598 ContextLine {
7599 on_top: dist <= ON_TOP_RADIUS_M,
7600 text: format!("Near: {label} ({dist:.1}m)"),
7601 },
7602 ));
7603 }
7604
7605 nearby.sort_by(|a, b| {
7606 a.0.partial_cmp(&b.0)
7607 .unwrap_or(std::cmp::Ordering::Equal)
7608 .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
7609 });
7610 lines.extend(nearby.into_iter().map(|(_, l)| l));
7611
7612 if lines.is_empty() {
7613 lines.push(ContextLine {
7614 on_top: false,
7615 text: "(nothing notable nearby)".into(),
7616 });
7617 }
7618
7619 lines
7620 }
7621}
7622
7623#[derive(Debug, Clone)]
7625pub struct ContextLine {
7626 pub on_top: bool,
7627 pub text: String,
7628}
7629
7630const ON_TOP_RADIUS_M: f32 = 0.65;
7631const NEARBY_SCAN_M: f32 = 5.0;
7632
7633pub fn resource_node_near_display_label(label: &str) -> String {
7635 label
7636 .strip_suffix(" (growing)")
7637 .unwrap_or(label)
7638 .to_string()
7639}
7640
7641fn resource_label_looks_like_raw_id(label: &str, id: &str) -> bool {
7642 let t = label.trim();
7643 if t.is_empty() || t == id {
7644 return true;
7645 }
7646 let lower = t.to_ascii_lowercase();
7647 if lower.contains("_copy") {
7648 return true;
7649 }
7650 false
7651}
7652
7653fn humanize_item_template_label(template: &str) -> String {
7654 let base = template.rsplit('/').next().unwrap_or(template).trim();
7655 if base.is_empty() {
7656 return "Resource".into();
7657 }
7658 let stripped = base
7659 .strip_prefix("crop-")
7660 .or_else(|| base.strip_prefix("crop_"))
7661 .unwrap_or(base);
7662 stripped
7663 .split(|c: char| c == '-' || c == '_')
7664 .filter(|p| !p.is_empty())
7665 .map(|p| {
7666 let mut chars = p.chars();
7667 match chars.next() {
7668 Some(c) => format!("{}{}", c.to_ascii_uppercase(), chars.as_str()),
7669 None => String::new(),
7670 }
7671 })
7672 .collect::<Vec<_>>()
7673 .join(" ")
7674}
7675
7676pub fn resource_node_id_suffix(id: &str) -> String {
7678 let chars: Vec<char> = id
7679 .chars()
7680 .rev()
7681 .filter(|c| c.is_ascii_alphanumeric())
7682 .take(4)
7683 .collect();
7684 chars.into_iter().rev().collect()
7685}
7686
7687pub fn resource_node_route_label(node: &flatland_protocol::ResourceNodeView) -> String {
7689 resource_node_route_label_parts(&node.id, &node.label, &node.item_template)
7690}
7691
7692pub fn resource_node_route_label_parts(id: &str, label: &str, item_template: &str) -> String {
7693 let cleaned = resource_node_near_display_label(label);
7694 let friendly = if !resource_label_looks_like_raw_id(&cleaned, id) {
7695 cleaned
7696 } else if !item_template.trim().is_empty() {
7697 humanize_item_template_label(item_template)
7698 } else {
7699 id.to_string()
7700 };
7701 let suffix = resource_node_id_suffix(id);
7702 if suffix.is_empty() {
7703 friendly
7704 } else {
7705 format!("{friendly} ({suffix})")
7706 }
7707}
7708
7709pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
7711 use flatland_protocol::ResourceNodeState;
7712 if node.harvest_off {
7713 return " (decorative)".to_string();
7714 }
7715 if let Some(p) = node.growth_progress {
7716 if p < 1.0 - f32::EPSILON {
7717 let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
7718 return format!(" (growing, {pct}%)");
7719 }
7720 return " — f harvest".to_string();
7721 }
7722 match node.state {
7723 ResourceNodeState::Available => " — f harvest".to_string(),
7724 ResourceNodeState::Harvesting => " (being harvested)".to_string(),
7725 ResourceNodeState::Cooldown => " (depleted)".to_string(),
7726 }
7727}
7728
7729fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
7730 use flatland_protocol::TerrainKindView;
7731 match kind {
7732 TerrainKindView::Grass => "Grass",
7733 TerrainKindView::Dirt => "Dirt",
7734 TerrainKindView::Tilled => "Tilled",
7735 TerrainKindView::Desert => "Desert",
7736 TerrainKindView::Hill => "Hills",
7737 TerrainKindView::Bog => "Bog",
7738 TerrainKindView::Beach => "Beach",
7739 TerrainKindView::ShallowWater => "Shallow water",
7740 TerrainKindView::DeepWater => "Deep water",
7741 TerrainKindView::Trail => "Trail",
7742 TerrainKindView::Road => "Road",
7743 TerrainKindView::Rock => "Rock",
7744 }
7745}
7746
7747fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
7748 crate::world_zones::zone_rects_contain(rects, x, y)
7749}
7750
7751fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
7752 zone.rects
7753 .iter()
7754 .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
7755 .sum()
7756}
7757
7758fn claim_rect_fully_inside_zone(
7759 zone: &flatland_protocol::PropertyZoneView,
7760 x0: f32,
7761 y0: f32,
7762 x1: f32,
7763 y1: f32,
7764) -> bool {
7765 let mut y = y0 + 0.5;
7766 while y < y1 {
7767 let mut x = x0 + 0.5;
7768 while x < x1 {
7769 if !zone_rects_contain(&zone.rects, x, y) {
7770 return false;
7771 }
7772 x += 1.0;
7773 }
7774 y += 1.0;
7775 }
7776 true
7777}
7778
7779fn rects_overlap_half_open(
7780 ax0: f32,
7781 ay0: f32,
7782 ax1: f32,
7783 ay1: f32,
7784 bx0: f32,
7785 by0: f32,
7786 bx1: f32,
7787 by1: f32,
7788) -> bool {
7789 ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
7790}
7791
7792fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
7793 x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
7794}
7795
7796fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
7797 plot_public_label(p)
7798}
7799
7800fn plot_size_fallback_label(p: &flatland_protocol::PropertyPlotView) -> String {
7801 let w = (p.x1 - p.x0).abs();
7802 let d = (p.y1 - p.y0).abs();
7803 format!("Plot ({w:.0}×{d:.0} m)")
7804}
7805
7806pub fn plot_public_label(p: &flatland_protocol::PropertyPlotView) -> String {
7808 let zone = p
7809 .zone_label
7810 .as_deref()
7811 .filter(|s| !s.trim().is_empty())
7812 .unwrap_or_else(|| {
7813 if p.property_zone_id.is_empty() {
7814 "Homestead"
7815 } else {
7816 p.property_zone_id.as_str()
7817 }
7818 });
7819 let label = if !p.label.trim().is_empty() {
7820 p.label.clone()
7821 } else if !p.plot_code.trim().is_empty() {
7822 p.plot_code.clone()
7823 } else {
7824 plot_size_fallback_label(p)
7825 };
7826 match p
7827 .owner_label
7828 .as_deref()
7829 .map(str::trim)
7830 .filter(|s| !s.is_empty())
7831 {
7832 Some(owner) => format!("{owner} — {zone} — {label}"),
7833 None => format!("{zone} — {label}"),
7834 }
7835}
7836
7837pub fn plot_stop_label(
7842 plots: &[flatland_protocol::PropertyPlotView],
7843 plot_id: uuid::Uuid,
7844) -> String {
7845 plots
7846 .iter()
7847 .find(|p| p.plot_id == plot_id)
7848 .map(plot_public_label)
7849 .unwrap_or_else(|| {
7850 let s = plot_id.to_string();
7851 format!("plot {}", s.get(..8).unwrap_or(s.as_str()))
7852 })
7853}
7854
7855fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
7857 let a = x0.min(x1).floor();
7858 let b = y0.min(y1).floor();
7859 let mut c = x0.max(x1).ceil();
7860 let mut d = y0.max(y1).ceil();
7861 if (c - a) < 1.0 {
7862 c = a + 1.0;
7863 }
7864 if (d - b) < 1.0 {
7865 d = b + 1.0;
7866 }
7867 (a, b, c, d)
7868}
7869
7870fn humanize_template_id(template_id: &str) -> String {
7871 if looks_like_template_uuid(template_id) {
7873 return "Unknown item".into();
7874 }
7875 template_id
7876 .split('_')
7877 .map(|word| {
7878 let mut chars = word.chars();
7879 match chars.next() {
7880 None => String::new(),
7881 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
7882 }
7883 })
7884 .collect::<Vec<_>>()
7885 .join(" ")
7886}
7887
7888fn looks_like_template_uuid(template_id: &str) -> bool {
7889 let bytes = template_id.as_bytes();
7890 if bytes.len() != 36 {
7891 return false;
7892 }
7893 let is_hex = |b: u8| b.is_ascii_hexdigit();
7894 let groups = [8usize, 4, 4, 4, 12];
7895 let mut i = 0;
7896 for (gi, &len) in groups.iter().enumerate() {
7897 if gi > 0 {
7898 if bytes.get(i) != Some(&b'-') {
7899 return false;
7900 }
7901 i += 1;
7902 }
7903 for _ in 0..len {
7904 if !bytes.get(i).copied().is_some_and(is_hex) {
7905 return false;
7906 }
7907 i += 1;
7908 }
7909 }
7910 true
7911}
7912
7913const HARVEST_RANGE_M: f32 = 1.5;
7915
7916pub struct GameClient<S: PlayConnection> {
7917 session: S,
7918 seq: Seq,
7919 pub state: GameState,
7920 last_move_forward: f32,
7921 last_move_strafe: f32,
7922}
7923
7924impl<S: PlayConnection> GameClient<S> {
7925 pub fn new(session: S) -> Self {
7926 let session_id = session.session_id();
7927 let entity_id = session.entity_id();
7928 let mut client = Self {
7929 session,
7930 seq: 0,
7931 last_move_forward: 0.0,
7932 last_move_strafe: 0.0,
7933 state: GameState {
7934 session_id,
7935 entity_id,
7936 character_id: None,
7937 tick: 0,
7938 chunk_rev: 0,
7939 content_rev: 0,
7940 publish_rev: 0,
7941 entities: Vec::new(),
7942 player: None,
7943 resource_nodes: Vec::new(),
7944 harvest_route_nodes: Vec::new(),
7945 ground_drops: Vec::new(),
7946 placed_containers: Vec::new(),
7947 buildings: Vec::new(),
7948 doors: Vec::new(),
7949 interior_map: None,
7950 npcs: Vec::new(),
7951 blueprints: Vec::new(),
7952 building_materials: Vec::new(),
7953 world_x0: 0.0,
7954 world_y0: 0.0,
7955 world_width_m: 0.0,
7956 world_height_m: 0.0,
7957 terrain_zones: Vec::new(),
7958 z_platforms: Vec::new(),
7959 z_transitions: Vec::new(),
7960 z_bands_outdoor_backup: None,
7961 world_clock: flatland_protocol::WorldClock::default(),
7962 inventory: std::collections::HashMap::new(),
7963 inventory_hints: std::collections::HashMap::new(),
7964 item_catalog: std::collections::HashMap::new(),
7965 logs: VecDeque::new(),
7966 intents_sent: 0,
7967 ticks_received: 0,
7968 connected: false,
7969 disconnect_reason: None,
7970 show_stats: false,
7971 hud_log_hidden: false,
7972 show_equip_menu: false,
7973 equip_menu_index: 0,
7974 show_craft_menu: false,
7975 show_plot_build_menu: false,
7976 plot_build_focus_wall: true,
7977 plot_build_wall_index: 0,
7978 plot_build_roof_index: 0,
7979 craft_menu_index: 0,
7980 craft_batch_quantity: 1,
7981 craft_tab: CraftTab::Ready,
7982 craft_filter: String::new(),
7983 craft_filter_focused: false,
7984 craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
7985 show_shop_menu: false,
7986 shop_catalog: None,
7987 bank_panel: None,
7988 bank_menu_index: 0,
7989 bank_ui_mode: BankUiMode::Menu,
7990 storage_panel: None,
7991 market_panel: None,
7992 market_menu_index: 0,
7993 market_filter: String::new(),
7994 market_filter_focused: false,
7995 market_category_filter: None,
7996 market_buy_confirm: None,
7997 market_ui_mode: MarketUiMode::Browse,
7998 storage_menu_index: 0,
7999 storage_ui_mode: StorageUiMode::Menu,
8000 shop_tab: ShopTab::default(),
8001 shop_menu_index: 0,
8002 shop_quantity: 1,
8003 shop_trade_log: VecDeque::new(),
8004 show_npc_verb_menu: false,
8005 npc_verb_target: None,
8006 npc_verb_index: 0,
8007 npc_verb_notice: None,
8008 player_verbs: crate::social::PlayerVerbState::default(),
8009 social_chat: crate::social::SocialChatState::default(),
8010 trade_ui: crate::social::TradeUiState::default(),
8011 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
8012 show_npc_chat: false,
8013 npc_chat: None,
8014 show_inventory_menu: false,
8015 inventory_menu_index: 0,
8016 inventory_tab: InventoryTab::OnPerson,
8017 inventory_filter: String::new(),
8018 inventory_filter_focused: false,
8019 show_move_picker: false,
8020 show_rename_prompt: false,
8021 rename_plot_id: None,
8022 highlighted_plot_id: None,
8023 show_worker_rename: false,
8024 rename_buffer: String::new(),
8025 move_picker_index: 0,
8026 move_picker: None,
8027 show_grant_picker: false,
8028 grant_picker_index: 0,
8029 grant_picker: None,
8030 show_destroy_picker: false,
8031 destroy_confirm_pending: false,
8032 destroy_picker: None,
8033 show_deconstruct_picker: false,
8034 deconstruct_confirm_pending: false,
8035 deconstruct_picker: None,
8036 combat_target: None,
8037 combat_target_label: None,
8038 ground_target: None,
8039 combat_fx: Vec::new(),
8040 ground_hazards: Vec::new(),
8041 property_zones: Vec::new(),
8042 tax_zones: Vec::new(),
8043 growth_zones: Vec::new(),
8044 biome_zones: Vec::new(),
8045 terrain_kind_nav: Vec::new(),
8046 property_plots: Vec::new(),
8047 property_plot_settings: None,
8048 claim_mode: None,
8049 relocate_mode: None,
8050 sell_plot_confirm: None,
8051 sell_plot_armed_at: None,
8052 show_plant_menu: false,
8053 plant_menu_index: 0,
8054 show_farm_access: false,
8055 farm_access_name_draft: String::new(),
8056 farm_access_discount_bps: 0,
8057 farm_access_index: 0,
8058 plant_quantity: 1,
8059 in_combat: false,
8060 auto_attack: true,
8061 combat_has_los: false,
8062 attack_cd_ticks: 0,
8063 gcd_ticks: 0,
8064 weapon_ability_id: "unarmed".into(),
8065 mainhand_template_id: None,
8066 mainhand_label: None,
8067 mainhand_instance_id: None,
8068 offhand_template_id: None,
8069 offhand_label: None,
8070 offhand_instance_id: None,
8071 mainhand_hand_slots: 1,
8072 defense: None,
8073 worn: BTreeMap::new(),
8074 carry_mass: 0.0,
8075 carry_mass_max: 0.0,
8076 encumbrance: flatland_protocol::EncumbranceState::Light,
8077 move_speed_mps: 0.0,
8078 move_speed_mult: 0.0,
8079 inventory_stacks: Vec::new(),
8080 keychain_stacks: Vec::new(),
8081 whisper_pouch_stacks: Vec::new(),
8082 combat_target_detail: None,
8083 statuses: Vec::new(),
8084 cast_progress: None,
8085 timed_channel: None,
8086 plot_build_offer: None,
8087 ability_cooldowns: Vec::new(),
8088 blocking_active: false,
8089 max_target_slots: 1,
8090 combat_slots: Vec::new(),
8091 rotation_presets: Vec::new(),
8092 known_abilities: Vec::new(),
8093 ability_meta: std::collections::HashMap::new(),
8094 ability_mastery: std::collections::HashMap::new(),
8095 hotbar: vec![None; 9],
8096 max_abilities_per_rotation: 0,
8097 show_loadout_menu: false,
8098 show_keychain_menu: false,
8099 keychain_menu_index: 0,
8100 show_rotation_editor: false,
8101 loadout_menu_index: 0,
8102 loadout_hotbar_slot: 1,
8103 loadout_ability_index: 0,
8104 loadout_focus_presets: false,
8105 rotation_editor: RotationEditorState::default(),
8106 harvest_in_progress: false,
8107 harvest_started_at: None,
8108 pending_craft_ack: None,
8109 craft_channel_blueprint_id: None,
8110 craft_channel_seen: false,
8111 pending_worker_job_ack: None,
8112 attending_worker_instance_id: None,
8113 quest_log: Vec::new(),
8114 interactables: Vec::new(),
8115 ledger: None,
8116 career: None,
8117 character_sheet_tab: CharacterSheetTab::Character,
8118 ledger_period: LedgerPeriod::Day,
8119 show_quest_offer: false,
8120 pending_quest_offers: Vec::new(),
8121 quest_offer_index: 0,
8122 show_quest_menu: false,
8123 quest_menu_index: 0,
8124 quest_withdraw_confirm: false,
8125 hired_workers: Vec::new(),
8126 show_workers_menu: false,
8127 workers_menu_index: 0,
8128 worker_dismiss_confirmation: None,
8129 workers_menu_compact: false,
8130 worker_step_display: BTreeMap::new(),
8131 worker_error_display: BTreeMap::new(),
8132 worker_health_ring_until: BTreeMap::new(),
8133 pending_worker_hire_since: None,
8134 show_worker_give_picker: false,
8135 worker_give_picker_index: 0,
8136 worker_give_picker: None,
8137 show_worker_give_target_picker: false,
8138 worker_give_target_picker_index: 0,
8139 worker_give_target_picker: None,
8140 show_worker_take_picker: false,
8141 worker_take_picker_index: 0,
8142 worker_take_picker: None,
8143 show_worker_teach_picker: false,
8144 worker_teach_picker_index: 0,
8145 worker_teach_picker: None,
8146 worker_route_editor: None,
8147 progression_curve: None,
8148 },
8149 };
8150 client.state.apply_client_ui_prefs();
8151 client
8152 }
8153
8154 pub fn entity_id(&self) -> EntityId {
8155 self.state.entity_id
8156 }
8157
8158 pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
8159 if self.state.connected {
8160 return Ok(());
8161 }
8162
8163 loop {
8164 match self.session.next_event().await {
8165 Some(SessionEvent::Welcome {
8166 session_id,
8167 entity_id,
8168 snapshot,
8169 }) => {
8170 self.state
8171 .restore_from_welcome(session_id, entity_id, &snapshot);
8172 self.state.apply_client_ui_prefs();
8173 self.state.push_log(format!(
8174 "Connected — session {session_id}, entity {entity_id}"
8175 ));
8176 return Ok(());
8177 }
8178 Some(SessionEvent::Disconnected { .. }) => {
8179 anyhow::bail!("disconnected before welcome");
8180 }
8181 Some(_) => continue,
8182 None => anyhow::bail!("session closed before welcome"),
8183 }
8184 }
8185 }
8186
8187 pub fn drain_events(&mut self) {
8189 while let Some(event) = self.session.try_next_event() {
8190 if self.handle_event_sync(event).is_err() {
8191 break;
8192 }
8193 }
8194 }
8195
8196 pub async fn next_event(&mut self) -> Option<SessionEvent> {
8198 self.session.next_event().await
8199 }
8200
8201 pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
8202 self.handle_event_sync(event)
8203 }
8204
8205 fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
8206 match event {
8207 SessionEvent::Welcome {
8208 session_id,
8209 entity_id,
8210 snapshot,
8211 } => {
8212 let resumed = self.state.connected;
8213 self.state
8214 .restore_from_welcome(session_id, entity_id, &snapshot);
8215 if resumed {
8216 self.state.push_log(format!(
8217 "Session restored — session {session_id}, entity {entity_id}"
8218 ));
8219 }
8220 }
8221 SessionEvent::ContentUpdated { snapshot } => {
8222 self.state
8223 .apply_snapshot_fields(&snapshot, self.state.entity_id);
8224 self.state.push_log(format!(
8225 "World updated (content rev {})",
8226 snapshot.content_rev
8227 ));
8228 }
8229 SessionEvent::QuestCatalogUpdated(update) => {
8230 self.state.push_log(format!(
8231 "Quest board updated (revision {}, {} new, {} retired)",
8232 update.revision,
8233 update.accepted.len(),
8234 update.retired.len()
8235 ));
8236 }
8237 SessionEvent::Tick(delta) => {
8238 self.state.apply_tick_fields(&delta, self.state.entity_id);
8239 self.state.ticks_received += 1;
8240 }
8241 SessionEvent::IntentAck {
8242 entity_id,
8243 seq,
8244 tick,
8245 } => {
8246 crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
8247 if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
8248 if *craft_seq == seq {
8249 let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
8250 if batches > 1 {
8251 self.state.push_log(format!("Crafting {label} ×{batches}…"));
8252 } else {
8253 self.state.push_log(format!("Crafting {label}…"));
8254 }
8255 }
8256 }
8257 if self
8258 .state
8259 .pending_worker_job_ack
8260 .as_ref()
8261 .is_some_and(|p| p.seq == seq)
8262 {
8263 let pending = self.state.pending_worker_job_ack.take().unwrap();
8264 if pending.idle {
8265 self.state.push_log(format!(
8266 "Route cleared for {} — worker idle",
8267 pending.worker_label
8268 ));
8269 } else {
8270 self.state.push_log(format!(
8271 "Route saved for {} — {} stop(s), job loop active",
8272 pending.worker_label, pending.stop_count
8273 ));
8274 }
8275 if self
8276 .state
8277 .worker_route_editor
8278 .as_ref()
8279 .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
8280 {
8281 self.close_worker_route_editor();
8282 }
8283 }
8284 }
8285 SessionEvent::Chat(msg) => {
8286 let label = match msg.channel {
8287 flatland_protocol::ChatChannel::Nearby => "nearby",
8288 flatland_protocol::ChatChannel::Direct => "speak",
8289 flatland_protocol::ChatChannel::Whisper => "whisper",
8290 flatland_protocol::ChatChannel::WhisperStone => "stone",
8291 };
8292 let clarity = match msg.clarity {
8293 flatland_protocol::ChatClarity::Clear => "",
8294 flatland_protocol::ChatClarity::Partial => "~",
8295 flatland_protocol::ChatClarity::Heavy => "…",
8296 };
8297 self.state.push_log(format!(
8298 "[{label}{clarity}] {}: {}",
8299 msg.from_name, msg.text
8300 ));
8301 let now_ms = std::time::SystemTime::now()
8302 .duration_since(std::time::UNIX_EPOCH)
8303 .map(|d| d.as_millis() as u64)
8304 .unwrap_or(0);
8305 self.state
8306 .social_chat
8307 .note_speech(&msg, self.state.entity_id, now_ms);
8308 self.state
8309 .social_chat
8310 .push(crate::social::ChatLogEntry::from_message(
8311 msg,
8312 self.state.entity_id,
8313 ));
8314 }
8315 SessionEvent::TradeOpened(panel) => {
8316 self.state.social_chat.pending_trade = None;
8317 let peer = panel.peer_name.clone();
8318 self.state.trade_ui.open(panel);
8319 self.state.social_chat.push_system(format!(
8320 "Trade open with {peer} — p present · r ready · Esc cancel"
8321 ));
8322 self.state
8323 .social_chat
8324 .push_cue(crate::social::AudioCue::TradeOpened);
8325 }
8326 SessionEvent::TradeClosed { reason } => {
8327 self.state.push_log(reason.clone());
8328 self.state.social_chat.push_system(reason);
8329 self.state.trade_ui.close();
8330 }
8331 SessionEvent::HarvestResult(result) => {
8332 self.state.clear_harvest_state();
8333 crate::harvest_trace!(
8334 entity_id = self.state.entity_id,
8335 node_id = %result.node_id,
8336 template = %result.item_template,
8337 quantity = result.quantity,
8338 client_tick = self.state.tick,
8339 "client applied harvest result"
8340 );
8341 let msg = if result.quantity == 0 {
8342 format!(
8343 "Harvested {} x0 — nothing dropped (loot table rolled empty)",
8344 result.item_template
8345 )
8346 } else {
8347 format!(
8348 "Harvested {} x{} (on the ground — press P to pick up)",
8349 result.item_template, result.quantity
8350 )
8351 };
8352 self.state.push_log(msg);
8353 }
8354 SessionEvent::CraftResult(result) => {
8355 for stack in &result.consumed {
8356 if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
8357 *qty = qty.saturating_sub(stack.quantity);
8358 if *qty == 0 {
8359 self.state.inventory.remove(&stack.template_id);
8360 }
8361 }
8362 }
8363 for stack in &result.outputs {
8364 *self
8365 .state
8366 .inventory
8367 .entry(stack.template_id.clone())
8368 .or_insert(0) += stack.quantity;
8369 }
8370 self.state.craft_record_completed(&result.blueprint_id);
8371 if let Some(output) = result.outputs.first() {
8372 if result.batch_total > 1 {
8373 self.state.push_log(format!(
8374 "Crafted {} x{} ({}/{})",
8375 output.template_id,
8376 output.quantity,
8377 result.batch_index,
8378 result.batch_total
8379 ));
8380 } else {
8381 self.state.push_log(format!(
8382 "Crafted {} x{}",
8383 output.template_id, output.quantity
8384 ));
8385 }
8386 } else {
8387 self.state
8388 .push_log(format!("Craft finished: {}", result.blueprint_id));
8389 }
8390 }
8391 SessionEvent::Death(notice) => {
8392 self.state.clear_harvest_state();
8393 self.state.push_log(notice.message.clone());
8394 self.state.push_log(format!(
8395 "Respawned at ({:.1}, {:.1})",
8396 notice.respawn_x, notice.respawn_y
8397 ));
8398 }
8399 SessionEvent::Interaction(notice) => {
8400 if notice.message.starts_with("Harvest failed:") {
8401 self.state.clear_harvest_state();
8402 }
8403 if notice.message.starts_with("Can't do that:") {
8404 self.state.pending_worker_hire_since = None;
8405 self.state.pending_craft_ack = None;
8406 self.state.clear_craft_ready_pin();
8407 if let Some(pending) = self.state.pending_worker_job_ack.take() {
8408 if let Some(w) = self
8409 .state
8410 .hired_workers
8411 .iter_mut()
8412 .find(|w| w.instance_id == pending.worker_instance_id)
8413 {
8414 w.route = pending.prev_route;
8415 w.mode = pending.prev_mode;
8416 w.step_label = pending.prev_step_label;
8417 w.last_error = pending.prev_last_error;
8418 }
8419 let reason = notice
8420 .message
8421 .strip_prefix("Can't do that:")
8422 .unwrap_or(¬ice.message)
8423 .trim();
8424 self.state.push_log(format!(
8425 "Route save failed for {}: {reason}",
8426 pending.worker_label
8427 ));
8428 }
8429 let reason = notice
8430 .message
8431 .strip_prefix("Can't do that:")
8432 .unwrap_or(¬ice.message)
8433 .trim();
8434 if reason.contains("already tilled") {
8435 if let Some(plot) = self.state.my_plot_under_player() {
8436 self.state.sell_plot_confirm = Some(plot.plot_id);
8437 self.state.sell_plot_armed_at = Some(Instant::now());
8438 }
8439 }
8440 }
8441 if notice.message.starts_with("Cast failed:") {
8442 self.state.cast_progress = None;
8443 }
8444 if notice.message.contains("slain the") {
8445 self.state.combat_target = None;
8446 self.state.combat_target_label = None;
8447 }
8448 if notice.message.contains("wants to trade") {
8450 if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
8451 let from_name = notice
8452 .message
8453 .split(" wants to trade")
8454 .next()
8455 .unwrap_or("Player")
8456 .to_string();
8457 self.state.social_chat.pending_trade =
8458 Some(crate::social::PendingTradeRequest {
8459 from_entity,
8460 from_name: from_name.clone(),
8461 });
8462 self.state.social_chat.push_system(format!(
8463 "{from_name} wants to trade — [Y] accept · [N] decline"
8464 ));
8465 self.state
8466 .social_chat
8467 .push_cue(crate::social::AudioCue::TradeOffer);
8468 }
8469 }
8470 if notice.message.starts_with("trade request declined") {
8471 self.state.social_chat.push_system(notice.message.clone());
8472 self.state
8473 .social_chat
8474 .push_cue(crate::social::AudioCue::TradeDeclined);
8475 }
8476 if self.state.show_npc_verb_menu && !notice.message.trim().is_empty() {
8478 self.state.npc_verb_notice = Some(notice.message.clone());
8479 self.state
8480 .social_chat
8481 .push_cue(crate::social::AudioCue::UiError);
8482 }
8483 self.state.apply_interaction_notice(¬ice);
8484 self.state.push_log(notice.message.clone());
8485 }
8486 SessionEvent::ShopOpened(catalog) => {
8487 self.state.apply_shop_catalog(catalog);
8488 }
8489 SessionEvent::BankOpened(panel) => {
8490 self.state.apply_bank_panel(panel);
8491 }
8492 SessionEvent::StorageOpened(panel) => {
8493 self.state.apply_storage_panel(panel);
8494 }
8495 SessionEvent::MarketOpened(panel) => {
8496 self.state.apply_market_panel(panel);
8497 }
8498 SessionEvent::NpcTalkOpened(opened) => {
8499 self.state.show_npc_verb_menu = false;
8500 if self.state.npc_verb_target.is_none() {
8501 self.state.npc_verb_target = Some(opened.npc_id.clone());
8502 }
8503 let label = opened.npc_label.clone();
8504 let banner = if !opened.trade_allowed {
8505 Some("Trade is unavailable right now.".to_string())
8506 } else {
8507 None
8508 };
8509 self.state.show_npc_chat = true;
8510 self.state.npc_chat = Some(NpcChatState {
8511 npc_id: opened.npc_id,
8512 npc_label: opened.npc_label,
8513 lines: if opened.greeting.is_empty() {
8514 vec![]
8515 } else {
8516 vec![format!("{label}: {}", opened.greeting)]
8517 },
8518 input: String::new(),
8519 pending: opened.greeting.is_empty(),
8520 talk_depth: opened.talk_depth,
8521 trade_allowed: opened.trade_allowed,
8522 banner,
8523 suggested_topics: opened.suggested_topics,
8524 });
8525 }
8526 SessionEvent::NpcTalkPending(_) => {
8527 if let Some(chat) = self.state.npc_chat.as_mut() {
8528 chat.pending = true;
8529 }
8530 }
8531 SessionEvent::NpcTalkReply(reply) => {
8532 if let Some(chat) = self.state.npc_chat.as_mut() {
8533 if chat.npc_id == reply.npc_id {
8534 chat.pending = false;
8535 if reply.trade_disabled {
8536 chat.trade_allowed = false;
8537 chat.banner = Some("Trade is unavailable right now.".to_string());
8538 }
8539 if reply.wind_down {
8540 chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
8541 if chat.banner.is_none() {
8542 chat.banner =
8543 Some("They're wrapping up — keep it brief.".to_string());
8544 }
8545 }
8546 chat.lines
8547 .push(format!("{}: {}", chat.npc_label, reply.line));
8548 }
8549 }
8550 }
8551 SessionEvent::NpcTalkClosed(closed) => {
8552 if self
8553 .state
8554 .npc_chat
8555 .as_ref()
8556 .is_some_and(|c| c.npc_id == closed.npc_id)
8557 {
8558 self.state.show_npc_chat = false;
8559 self.state.npc_chat = None;
8560 }
8561 }
8562 SessionEvent::NpcTalkError(err) => {
8563 self.state.push_log(format!("Talk failed: {}", err.reason));
8564 if let Some(chat) = self.state.npc_chat.as_mut() {
8565 chat.pending = false;
8566 }
8567 }
8568 SessionEvent::UseResult(result) => {
8569 if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
8572 *qty = qty.saturating_sub(1);
8573 if *qty == 0 {
8574 self.state.inventory.remove(&result.template_id);
8575 }
8576 }
8577 }
8578 SessionEvent::QuestOffer(offer) => {
8579 let title = offer.title.clone();
8580 self.state.push_quest_offer(offer);
8581 self.state.push_log(format!("Quest offered: {title}"));
8582 }
8583 SessionEvent::QuestAccepted(notice) => {
8584 self.state.remove_quest_offer(¬ice.quest_id);
8585 self.state.push_log(notice.message);
8586 }
8587 SessionEvent::QuestWithdrawn(notice) => {
8588 self.state.show_quest_menu = false;
8589 self.state.quest_withdraw_confirm = false;
8590 self.state.push_log(notice.message);
8591 }
8592 SessionEvent::QuestStepCompleted(notice) => {
8593 self.state.push_log(notice.message);
8594 }
8595 SessionEvent::QuestCompleted(notice) => {
8596 self.state.push_log(notice.message);
8597 }
8598 SessionEvent::Disconnected { reason } => {
8599 self.state.clear_harvest_state();
8600 self.state.connected = false;
8601 self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
8602 if let Some(r) = &self.state.disconnect_reason {
8603 self.state.push_log(format!("Disconnected: {r}"));
8604 } else {
8605 self.state.push_log("Disconnected from server");
8606 }
8607 }
8608 }
8609 Ok(())
8610 }
8611
8612 pub fn is_connected(&self) -> bool {
8613 self.state.connected
8614 }
8615
8616 pub fn close_overlays(&mut self) {
8617 self.state.show_stats = false;
8618 self.state.show_craft_menu = false;
8619 self.state.show_plot_build_menu = false;
8620 self.state.show_shop_menu = false;
8621 self.state.shop_catalog = None;
8622 self.state.show_npc_verb_menu = false;
8623 self.state.npc_verb_target = None;
8624 self.state.show_npc_chat = false;
8625 self.state.npc_chat = None;
8626 self.state.show_inventory_menu = false;
8627 self.state.show_loadout_menu = false;
8628 self.state.show_rotation_editor = false;
8629 self.state.rotation_editor.reset();
8630 self.state.show_rename_prompt = false;
8631 self.state.show_worker_rename = false;
8632 self.state.rename_buffer.clear();
8633 self.state.show_move_picker = false;
8634 self.state.move_picker = None;
8635 self.state.show_destroy_picker = false;
8636 self.state.destroy_confirm_pending = false;
8637 self.state.destroy_picker = None;
8638 self.state.show_deconstruct_picker = false;
8639 self.state.deconstruct_confirm_pending = false;
8640 self.state.deconstruct_picker = None;
8641 self.state.show_quest_offer = false;
8642 self.state.clear_quest_offers();
8643 self.state.show_quest_menu = false;
8644 self.state.quest_withdraw_confirm = false;
8645 self.state.show_workers_menu = false;
8646 self.close_worker_give_picker();
8647 self.close_worker_give_target_picker();
8648 self.close_worker_take_picker();
8649 self.close_worker_teach_picker();
8650 self.state.worker_route_editor = None;
8651 self.state.claim_mode = None;
8652 self.state.relocate_mode = None;
8653 self.state.sell_plot_confirm = None;
8654 self.state.sell_plot_armed_at = None;
8655 self.close_farm_access_panel();
8656 if self.state.show_plant_menu {
8657 self.close_plant_menu();
8658 }
8659 }
8660
8661 pub fn back_on_esc(&mut self) -> bool {
8663 if self.state.social_chat.composer_open() {
8664 self.state.social_chat.close_composer();
8665 return true;
8666 }
8667 if self.state.player_verbs.open {
8668 self.state.player_verbs.close();
8669 return true;
8670 }
8671 if self.state.whisper_pouch_ui.open {
8672 self.state.whisper_pouch_ui.open = false;
8673 return true;
8674 }
8675 if self.state.trade_ui.panel.is_some() {
8676 self.state.trade_ui.close();
8678 return true;
8679 }
8680 if self.state.show_rename_prompt {
8681 self.cancel_rename_prompt();
8682 return true;
8683 }
8684 if self.state.show_worker_rename {
8685 self.cancel_worker_rename();
8686 return true;
8687 }
8688 if self.state.show_destroy_picker {
8689 if self.state.destroy_confirm_pending {
8690 self.cancel_destroy_confirm();
8691 } else {
8692 self.close_destroy_picker();
8693 }
8694 return true;
8695 }
8696 if self.state.show_deconstruct_picker {
8697 if self.state.deconstruct_confirm_pending {
8698 self.cancel_deconstruct_confirm();
8699 } else {
8700 self.close_deconstruct_picker();
8701 }
8702 return true;
8703 }
8704 if self.state.claim_mode.is_some() {
8705 self.cancel_claim_mode();
8706 return true;
8707 }
8708 if self.state.relocate_mode.is_some() {
8709 self.cancel_relocate_mode();
8710 return true;
8711 }
8712 if self.state.show_plant_menu {
8713 self.close_plant_menu();
8714 return true;
8715 }
8716 if self.state.show_farm_access {
8717 self.close_farm_access_panel();
8718 return true;
8719 }
8720 if self.state.sell_plot_confirm.is_some() {
8721 self.state.sell_plot_confirm = None;
8722 self.state.sell_plot_armed_at = None;
8723 self.state.push_log("Sell cancelled");
8724 return true;
8725 }
8726 if self.state.show_move_picker {
8727 self.close_move_picker();
8728 return true;
8729 }
8730 if self.state.show_rotation_editor {
8731 match self.state.rotation_editor.mode {
8732 RotationEditorMode::List => {
8733 self.state.show_rotation_editor = false;
8734 self.state.rotation_editor.reset();
8735 }
8736 RotationEditorMode::EditLabel => {
8737 self.state.rotation_editor.label_buffer.clear();
8738 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
8739 }
8740 RotationEditorMode::PickAbility => {
8741 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
8742 }
8743 RotationEditorMode::EditSequence => {
8744 self.state.rotation_editor.draft = None;
8745 self.state.rotation_editor.mode = RotationEditorMode::List;
8746 }
8747 }
8748 return true;
8749 }
8750 if self.state.show_inventory_menu {
8751 self.close_inventory_menu();
8752 return true;
8753 }
8754 if self.state.show_craft_menu {
8755 self.close_craft_menu();
8756 return true;
8757 }
8758 if self.state.show_plot_build_menu {
8759 self.close_plot_build_menu();
8760 return true;
8761 }
8762 if self.state.show_keychain_menu {
8763 self.close_keychain_menu();
8764 return true;
8765 }
8766 if self.state.show_quest_offer {
8767 self.quest_offer_decline();
8768 return true;
8769 }
8770 if self.state.show_shop_menu {
8771 return false;
8773 }
8774 if self.state.bank_panel.is_some() {
8775 return false;
8776 }
8777 if self.state.storage_panel.is_some() {
8778 return false;
8779 }
8780 if self.state.market_panel.is_some() {
8781 return false;
8782 }
8783 if self.state.show_npc_chat {
8784 return false;
8786 }
8787 if self.state.show_npc_verb_menu {
8788 self.state.show_npc_verb_menu = false;
8789 self.state.npc_verb_target = None;
8790 self.state.npc_verb_notice = None;
8791 return true;
8792 }
8793 if self.state.show_quest_menu {
8794 if self.state.quest_withdraw_confirm {
8795 self.state.quest_withdraw_confirm = false;
8796 } else {
8797 self.state.show_quest_menu = false;
8798 }
8799 return true;
8800 }
8801 if self.state.worker_route_editor.is_some() {
8802 if self.re_at_root_sheet() {
8804 let reopen = self.state.attending_worker_instance_id.clone();
8805 self.close_worker_route_editor();
8806 if let Some(id) = reopen {
8807 if let Some(idx) = self
8808 .state
8809 .hired_workers
8810 .iter()
8811 .position(|w| w.instance_id == id)
8812 {
8813 self.state.workers_menu_index = idx;
8814 self.state.show_workers_menu = true;
8815 }
8816 }
8817 } else {
8818 self.re_sheet_back();
8819 }
8820 return true;
8821 }
8822 if self.state.show_worker_give_picker {
8823 self.close_worker_give_picker();
8824 return true;
8825 }
8826 if self.state.show_worker_give_target_picker {
8827 self.close_worker_give_target_picker();
8828 return true;
8829 }
8830 if self.state.show_worker_take_picker {
8831 self.close_worker_take_picker();
8832 return true;
8833 }
8834 if self.state.show_worker_teach_picker {
8835 self.close_worker_teach_picker();
8836 return true;
8837 }
8838 if self.state.show_workers_menu {
8839 self.close_workers_menu_ui();
8840 return true;
8841 }
8842 if self.state.show_loadout_menu {
8843 self.state.show_loadout_menu = false;
8844 return true;
8845 }
8846 if self.state.show_stats {
8847 self.state.show_stats = false;
8848 return true;
8849 }
8850 if self.state.show_equip_menu {
8851 self.state.show_equip_menu = false;
8852 return true;
8853 }
8854 false
8855 }
8856
8857 pub fn toggle_stats(&mut self) {
8858 self.state.show_stats = !self.state.show_stats;
8859 if self.state.show_stats {
8860 self.state.character_sheet_tab = CharacterSheetTab::Character;
8861 self.state.show_craft_menu = false;
8862 self.state.show_shop_menu = false;
8863 self.state.shop_catalog = None;
8864 self.state.show_inventory_menu = false;
8865 self.state.show_equip_menu = false;
8866 }
8867 }
8868
8869 pub fn toggle_equip_menu(&mut self) {
8870 self.state.show_equip_menu = !self.state.show_equip_menu;
8871 if self.state.show_equip_menu {
8872 self.state.show_stats = false;
8873 self.state.show_craft_menu = false;
8874 self.state.show_shop_menu = false;
8875 self.state.shop_catalog = None;
8876 self.state.show_inventory_menu = false;
8877 self.state.show_loadout_menu = false;
8878 }
8879 }
8880
8881 pub fn cycle_character_sheet_tab(&mut self) {
8882 if self.state.show_stats {
8883 self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
8884 }
8885 }
8886
8887 pub fn set_ledger_period_digit(&mut self, c: char) {
8888 if self.state.show_stats {
8889 if let Some(p) = LedgerPeriod::from_digit(c) {
8890 self.state.ledger_period = p;
8891 self.state.character_sheet_tab = CharacterSheetTab::Ledger;
8892 }
8893 }
8894 }
8895
8896 pub fn cycle_ledger_period(&mut self) {
8897 if self.state.show_stats && self.state.character_sheet_tab == CharacterSheetTab::Ledger {
8898 self.state.ledger_period = self.state.ledger_period.cycle();
8899 }
8900 }
8901
8902 pub fn open_inventory_menu(&mut self) {
8903 self.state.social_chat.picking_stone = false;
8904 self.state.show_inventory_menu = true;
8905 self.state.show_craft_menu = false;
8906 self.state.show_shop_menu = false;
8907 self.state.shop_catalog = None;
8908 self.state.show_stats = false;
8909 self.state.show_move_picker = false;
8910 self.state.move_picker = None;
8911 self.state.show_destroy_picker = false;
8912 self.state.destroy_confirm_pending = false;
8913 self.state.destroy_picker = None;
8914 self.state.show_deconstruct_picker = false;
8915 self.state.deconstruct_confirm_pending = false;
8916 self.state.deconstruct_picker = None;
8917 self.state.show_rename_prompt = false;
8918 self.state.rename_plot_id = None;
8919 self.state.rename_buffer.clear();
8920 self.state.inventory_filter_focused = false;
8921 self.state.clamp_inventory_indices();
8922 }
8923
8924 pub fn close_inventory_menu(&mut self) {
8925 self.state.show_inventory_menu = false;
8926 self.state.show_move_picker = false;
8927 self.state.move_picker = None;
8928 self.close_grant_picker();
8929 self.state.show_destroy_picker = false;
8930 self.state.destroy_confirm_pending = false;
8931 self.state.destroy_picker = None;
8932 self.state.show_deconstruct_picker = false;
8933 self.state.deconstruct_confirm_pending = false;
8934 self.state.deconstruct_picker = None;
8935 self.state.show_rename_prompt = false;
8936 self.state.rename_plot_id = None;
8937 self.state.rename_buffer.clear();
8938 self.state.inventory_filter_focused = false;
8939 }
8940
8941 pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
8942 let Some(row) = self.state.inventory_selected_row() else {
8943 anyhow::bail!("inventory empty");
8944 };
8945 if GameState::is_property_deed_template(&row.stack.template_id) {
8946 let Some(plot_id) = GameState::deed_plot_id(&row.stack) else {
8947 anyhow::bail!("deed has no plot id");
8948 };
8949 let label = self
8950 .state
8951 .property_plots
8952 .iter()
8953 .find(|p| p.plot_id == plot_id)
8954 .map(|p| {
8955 if p.label.trim().is_empty() {
8956 p.plot_code.clone()
8957 } else {
8958 p.label.clone()
8959 }
8960 })
8961 .unwrap_or_else(|| {
8962 row.stack
8963 .display_name
8964 .clone()
8965 .unwrap_or_else(|| "plot".into())
8966 });
8967 self.state.rename_buffer = label;
8968 self.state.rename_plot_id = Some(plot_id);
8969 self.state.highlighted_plot_id = Some(plot_id);
8970 self.state.show_rename_prompt = true;
8971 self.state.show_worker_rename = false;
8972 self.state.show_move_picker = false;
8973 self.state.show_destroy_picker = false;
8974 self.state.destroy_confirm_pending = false;
8975 self.close_deconstruct_picker();
8976 return Ok(());
8977 }
8978 if !self.state.row_is_renameable_container(&row) {
8979 anyhow::bail!("only storage containers or deeds can be renamed");
8980 }
8981 let current = row
8982 .stack
8983 .display_name
8984 .clone()
8985 .unwrap_or_else(|| row.stack.template_id.clone());
8986 self.state.rename_buffer = current;
8987 self.state.rename_plot_id = None;
8988 self.state.show_rename_prompt = true;
8989 self.state.show_worker_rename = false;
8990 self.state.show_move_picker = false;
8991 self.state.show_destroy_picker = false;
8992 self.state.destroy_confirm_pending = false;
8993 self.close_deconstruct_picker();
8994 Ok(())
8995 }
8996
8997 pub fn open_plot_rename_under_player(&mut self) -> anyhow::Result<()> {
8999 let Some(plot) = self.state.my_plot_under_player().cloned() else {
9000 anyhow::bail!("stand on your plot to rename it");
9001 };
9002 let label = if plot.label.trim().is_empty() {
9003 plot.plot_code.clone()
9004 } else {
9005 plot.label.clone()
9006 };
9007 self.state.rename_buffer = label;
9008 self.state.rename_plot_id = Some(plot.plot_id);
9009 self.state.highlighted_plot_id = Some(plot.plot_id);
9010 self.state.show_rename_prompt = true;
9011 self.state.show_worker_rename = false;
9012 Ok(())
9013 }
9014
9015 pub fn cancel_rename_prompt(&mut self) {
9016 self.state.show_rename_prompt = false;
9017 self.state.rename_plot_id = None;
9018 self.state.rename_buffer.clear();
9019 }
9020
9021 pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
9022 let name = self.state.rename_buffer.trim().to_string();
9023 if name.is_empty() {
9024 anyhow::bail!("name cannot be empty");
9025 }
9026 if let Some(plot_id) = self.state.rename_plot_id {
9027 if name.chars().count() > 48 {
9028 anyhow::bail!("label must be 1–48 characters");
9029 }
9030 self.seq += 1;
9031 self.session
9032 .submit_intent(Intent::RenamePropertyPlot {
9033 entity_id: self.state.entity_id,
9034 plot_id,
9035 label: name,
9036 seq: self.seq,
9037 })
9038 .await?;
9039 self.state.intents_sent += 1;
9040 self.state.show_rename_prompt = false;
9041 self.state.rename_plot_id = None;
9042 self.state.rename_buffer.clear();
9043 return Ok(());
9044 }
9045 if name.chars().count() > 32 {
9046 anyhow::bail!("name must be 1–32 characters");
9047 }
9048 let Some(row) = self.state.inventory_selected_row() else {
9049 anyhow::bail!("inventory empty");
9050 };
9051 let Some(instance_id) = row.stack.item_instance_id else {
9052 anyhow::bail!("item has no instance id");
9053 };
9054 self.seq += 1;
9055 self.session
9056 .submit_intent(Intent::RenameContainer {
9057 entity_id: self.state.entity_id,
9058 item_instance_id: instance_id,
9059 location: row.from.clone(),
9060 name,
9061 seq: self.seq,
9062 })
9063 .await?;
9064 self.state.intents_sent += 1;
9065 self.state.show_rename_prompt = false;
9066 self.state.rename_buffer.clear();
9067 Ok(())
9068 }
9069
9070 pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
9071 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
9072 anyhow::bail!("no worker selected");
9073 };
9074 self.state.rename_buffer = worker.label.clone();
9075 self.state.show_worker_rename = true;
9076 self.state.show_rename_prompt = false;
9077 Ok(())
9078 }
9079
9080 pub fn cancel_worker_rename(&mut self) {
9081 self.state.show_worker_rename = false;
9082 self.state.rename_buffer.clear();
9083 }
9084
9085 pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
9086 let name = self.state.rename_buffer.trim().to_string();
9087 if name.is_empty() {
9088 anyhow::bail!("name cannot be empty");
9089 }
9090 if name.chars().count() > 32 {
9091 anyhow::bail!("name must be 1–32 characters");
9092 }
9093 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
9094 anyhow::bail!("no worker selected");
9095 };
9096 let worker_instance_id = worker.instance_id.clone();
9097 self.seq += 1;
9098 self.session
9099 .submit_intent(Intent::RenameHiredWorker {
9100 entity_id: self.state.entity_id,
9101 worker_instance_id: worker_instance_id.clone(),
9102 name: name.clone(),
9103 seq: self.seq,
9104 })
9105 .await?;
9106 self.state.intents_sent += 1;
9107 if let Some(w) = self
9108 .state
9109 .hired_workers
9110 .iter_mut()
9111 .find(|w| w.instance_id == worker_instance_id)
9112 {
9113 w.label = name.clone();
9114 }
9115 if let Some(ed) = self.state.worker_route_editor.as_mut() {
9116 if ed.worker_instance_id == worker_instance_id {
9117 ed.worker_label = name.clone();
9118 }
9119 }
9120 self.state.show_worker_rename = false;
9121 self.state.rename_buffer.clear();
9122 self.state.push_log(format!("Renamed worker to \"{name}\""));
9123 Ok(())
9124 }
9125
9126 pub fn toggle_inventory_menu(&mut self) {
9127 if self.state.show_inventory_menu {
9128 self.close_inventory_menu();
9129 } else {
9130 self.open_inventory_menu();
9131 }
9132 }
9133
9134 pub fn inventory_menu_move(&mut self, delta: i32) {
9136 if self.state.show_grant_picker {
9137 let Some(picker) = self.state.grant_picker.as_ref() else {
9138 return;
9139 };
9140 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
9141 let filter = picker.filter.clone();
9142 let n = labels.len();
9143 if n == 0 {
9144 return;
9145 }
9146 self.state.grant_picker_index =
9147 step_filtered_index(self.state.grant_picker_index, delta, n, |i| {
9148 list_label_matches(&labels[i], &filter)
9149 });
9150 return;
9151 }
9152 if self.state.show_move_picker {
9153 let Some(picker) = self.state.move_picker.as_ref() else {
9154 return;
9155 };
9156 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
9157 let filter = picker.filter.clone();
9158 let n = labels.len();
9159 if n == 0 {
9160 return;
9161 }
9162 self.state.move_picker_index =
9163 step_filtered_index(self.state.move_picker_index, delta, n, |i| {
9164 list_label_matches(&labels[i], &filter)
9165 });
9166 self.state.clamp_move_picker_quantity();
9167 return;
9168 }
9169 let n = self.state.inventory_selectable_rows().len();
9170 if n == 0 {
9171 return;
9172 }
9173 let idx = self.state.inventory_menu_index as i32;
9174 self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9175 }
9176
9177 pub fn inventory_menu_page(&mut self, pages: i32) {
9179 if self.state.show_grant_picker {
9180 let Some(picker) = self.state.grant_picker.as_ref() else {
9181 return;
9182 };
9183 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
9184 let filter = picker.filter.clone();
9185 let n = labels.len();
9186 self.state.grant_picker_index =
9187 page_filtered_index(self.state.grant_picker_index, pages, n, |i| {
9188 list_label_matches(&labels[i], &filter)
9189 });
9190 return;
9191 }
9192 if self.state.show_move_picker {
9193 let Some(picker) = self.state.move_picker.as_ref() else {
9194 return;
9195 };
9196 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
9197 let filter = picker.filter.clone();
9198 let n = labels.len();
9199 self.state.move_picker_index =
9200 page_filtered_index(self.state.move_picker_index, pages, n, |i| {
9201 list_label_matches(&labels[i], &filter)
9202 });
9203 self.state.clamp_move_picker_quantity();
9204 return;
9205 }
9206 let n = self.state.inventory_selectable_rows().len();
9207 self.state.inventory_menu_index =
9208 page_list_index(self.state.inventory_menu_index, pages, n);
9209 }
9210
9211 pub fn cycle_inventory_tab(&mut self, forward: bool) {
9212 if self.state.show_move_picker
9213 || self.state.show_grant_picker
9214 || self.state.show_destroy_picker
9215 || self.state.show_deconstruct_picker
9216 || self.state.show_rename_prompt
9217 || self.state.inventory_filter_focused
9218 {
9219 return;
9220 }
9221 self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
9222 self.state.inventory_menu_index = 0;
9223 self.state.clamp_inventory_indices();
9224 }
9225
9226 pub fn focus_inventory_filter(&mut self) {
9227 if self.state.show_grant_picker {
9228 if let Some(p) = self.state.grant_picker.as_mut() {
9229 p.filter_focused = true;
9230 }
9231 return;
9232 }
9233 if self.state.show_move_picker {
9234 if let Some(p) = self.state.move_picker.as_mut() {
9235 p.filter_focused = true;
9236 }
9237 return;
9238 }
9239 self.state.inventory_filter_focused = true;
9240 }
9241
9242 pub fn set_inventory_filter(&mut self, filter: String) {
9243 self.state.inventory_filter = filter;
9244 self.state.inventory_menu_index = 0;
9245 self.state.clamp_inventory_indices();
9246 }
9247
9248 pub fn append_inventory_filter_char(&mut self, ch: char) {
9249 if !is_list_filter_char(ch) {
9250 return;
9251 }
9252 if self.state.show_grant_picker {
9253 if let Some(p) = self.state.grant_picker.as_mut() {
9254 if p.filter_focused {
9255 p.filter.push(ch);
9256 self.state.grant_picker_index = 0;
9257 }
9258 }
9259 return;
9260 }
9261 if self.state.show_move_picker {
9262 if let Some(p) = self.state.move_picker.as_mut() {
9263 if p.filter_focused {
9264 p.filter.push(ch);
9265 self.state.move_picker_index = 0;
9266 self.state.clamp_move_picker_quantity();
9267 }
9268 }
9269 return;
9270 }
9271 if !self.state.inventory_filter_focused {
9272 return;
9273 }
9274 self.state.inventory_filter.push(ch);
9275 self.state.inventory_menu_index = 0;
9276 self.state.clamp_inventory_indices();
9277 }
9278
9279 pub fn inventory_filter_backspace(&mut self) {
9280 if self.state.show_grant_picker {
9281 if let Some(p) = self.state.grant_picker.as_mut() {
9282 if p.filter_focused {
9283 p.filter.pop();
9284 self.state.grant_picker_index = 0;
9285 }
9286 }
9287 return;
9288 }
9289 if self.state.show_move_picker {
9290 if let Some(p) = self.state.move_picker.as_mut() {
9291 if p.filter_focused {
9292 p.filter.pop();
9293 self.state.move_picker_index = 0;
9294 self.state.clamp_move_picker_quantity();
9295 }
9296 }
9297 return;
9298 }
9299 if !self.state.inventory_filter_focused {
9300 return;
9301 }
9302 self.state.inventory_filter.pop();
9303 self.state.inventory_menu_index = 0;
9304 self.state.clamp_inventory_indices();
9305 }
9306
9307 pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
9309 if self.state.show_grant_picker {
9310 if let Some(p) = self.state.grant_picker.as_mut() {
9311 if p.filter_focused {
9312 if !p.filter.is_empty() {
9313 p.filter.clear();
9314 self.state.grant_picker_index = 0;
9315 } else {
9316 p.filter_focused = false;
9317 }
9318 return true;
9319 }
9320 if !p.filter.is_empty() {
9321 p.filter.clear();
9322 self.state.grant_picker_index = 0;
9323 return true;
9324 }
9325 }
9326 return false;
9327 }
9328 if self.state.show_move_picker {
9329 if let Some(p) = self.state.move_picker.as_mut() {
9330 if p.filter_focused {
9331 if !p.filter.is_empty() {
9332 p.filter.clear();
9333 self.state.move_picker_index = 0;
9334 self.state.clamp_move_picker_quantity();
9335 } else {
9336 p.filter_focused = false;
9337 }
9338 return true;
9339 }
9340 if !p.filter.is_empty() {
9341 p.filter.clear();
9342 self.state.move_picker_index = 0;
9343 self.state.clamp_move_picker_quantity();
9344 return true;
9345 }
9346 }
9347 return false;
9348 }
9349 if self.state.inventory_filter_focused {
9350 if !self.state.inventory_filter.is_empty() {
9351 self.state.inventory_filter.clear();
9352 self.state.inventory_menu_index = 0;
9353 self.state.clamp_inventory_indices();
9354 } else {
9355 self.state.inventory_filter_focused = false;
9356 }
9357 return true;
9358 }
9359 if !self.state.inventory_filter.is_empty() {
9360 self.state.inventory_filter.clear();
9361 self.state.inventory_menu_index = 0;
9362 self.state.clamp_inventory_indices();
9363 return true;
9364 }
9365 false
9366 }
9367
9368 pub fn craft_menu_page(&mut self, pages: i32) {
9369 let n = self.state.craft_filtered_indices().len();
9370 self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
9371 self.state.clamp_craft_batch_quantity();
9372 }
9373
9374 pub fn shop_menu_page(&mut self, pages: i32) {
9375 let n = self.state.shop_list_len();
9376 self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
9377 self.state.clamp_shop_quantity();
9378 }
9379
9380 pub fn workers_menu_page(&mut self, pages: i32) {
9381 let n = self.state.hired_workers.len();
9382 self.state.workers_menu_index = page_list_index(self.state.workers_menu_index, pages, n);
9383 }
9384
9385 pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
9390 if self.state.show_destroy_picker {
9391 if self.state.destroy_confirm_pending {
9392 return self.confirm_destroy_item().await;
9393 }
9394 return self.request_destroy_confirm();
9395 }
9396 if self.state.show_deconstruct_picker {
9397 if self.state.deconstruct_confirm_pending {
9398 return self.confirm_deconstruct_item().await;
9399 }
9400 return self.request_deconstruct_confirm();
9401 }
9402 if self.state.show_grant_picker {
9403 return self.confirm_grant_picker().await;
9404 }
9405 if self.state.show_move_picker {
9406 return self.confirm_move_picker().await;
9407 }
9408 let Some(row) = self.state.inventory_selected_row() else {
9409 anyhow::bail!("inventory empty");
9410 };
9411 if row.is_equip_shell {
9412 let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
9413 anyhow::bail!("not a worn item");
9414 };
9415 return self.equip_worn(slot, None).await;
9416 }
9417 if row.is_chest_shell {
9418 return self.open_chest_pickup_picker();
9419 }
9420 let template_id = row.stack.template_id.clone();
9421 let instance_id = row.stack.item_instance_id;
9422 let category = self.state.inventory_item_category(&template_id);
9423 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
9424
9425 if category == Some("weapon") {
9426 return self.equip_mainhand(Some(template_id)).await;
9427 }
9428 if category == Some("lodging") && on_person {
9429 if let Some(inst) = instance_id {
9430 return self.place_container(inst).await;
9431 }
9432 }
9433 if on_person {
9435 if let Some(inst) = instance_id {
9436 if row.stack.world_placeable == Some(true) {
9437 return self.place_container(inst).await;
9438 }
9439 }
9440 }
9441 if (category == Some("container") || category == Some("armor")) && on_person {
9442 if let Some(inst) = instance_id {
9443 let world_placeable =
9444 row.stack.world_placeable == Some(true) || template_id.contains("chest");
9445 if world_placeable {
9446 return self.place_container(inst).await;
9447 }
9448 if let Some(slot) = guess_body_slot(&template_id) {
9452 return self.equip_worn(slot, Some(inst)).await;
9453 }
9454 }
9455 }
9456 self.open_move_picker()
9460 }
9461
9462 pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
9464 let Some(row) = self.state.inventory_selected_row() else {
9465 anyhow::bail!("inventory empty");
9466 };
9467 if row.from != flatland_protocol::InventoryLocation::Root {
9468 anyhow::bail!("select a consumable on your person");
9469 }
9470 if GameState::stack_is_item_grant(&row.stack) {
9471 return self.open_grant_target_picker();
9472 }
9473 if GameState::is_property_deed_template(&row.stack.template_id) {
9474 return self.open_move_picker();
9475 }
9476 let category = self.state.inventory_item_category(&row.stack.template_id);
9477 if category != Some("consumable") && !GameState::stack_is_serving(&row.stack) {
9478 anyhow::bail!("selected item is not usable");
9479 }
9480 self.use_item(&row.stack.template_id).await
9481 }
9482
9483 pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
9485 let Some(row) = self.state.inventory_selected_row() else {
9486 anyhow::bail!("inventory empty");
9487 };
9488 if row.from != flatland_protocol::InventoryLocation::Root {
9489 anyhow::bail!("select a grant item on your person");
9490 }
9491 if !GameState::stack_is_item_grant(&row.stack) {
9492 anyhow::bail!("selected item does not grant onto gear");
9493 }
9494 let Some(grant_instance_id) = row.stack.item_instance_id else {
9495 anyhow::bail!("grant has no instance id");
9496 };
9497 let effect_id = GameState::grant_effect_id(&row.stack)
9498 .unwrap_or("?")
9499 .to_string();
9500 let mode = GameState::grant_mode(&row.stack).to_string();
9501 let options = self.state.grant_target_options(&row.stack);
9502 if options.is_empty() {
9503 anyhow::bail!("no valid gear to apply {effect_id} to");
9504 }
9505 let grant_label = row
9506 .stack
9507 .display_name
9508 .clone()
9509 .unwrap_or_else(|| row.stack.template_id.clone());
9510 self.state.show_grant_picker = true;
9511 self.state.grant_picker_index = 0;
9512 self.state.grant_picker = Some(GrantTargetPicker {
9513 grant_instance_id,
9514 grant_label,
9515 effect_id,
9516 mode,
9517 options,
9518 filter: String::new(),
9519 filter_focused: false,
9520 });
9521 Ok(())
9522 }
9523
9524 pub fn close_grant_picker(&mut self) {
9525 self.state.show_grant_picker = false;
9526 self.state.grant_picker = None;
9527 self.state.grant_picker_index = 0;
9528 }
9529
9530 pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
9531 let Some(picker) = self.state.grant_picker.clone() else {
9532 self.close_grant_picker();
9533 return Ok(());
9534 };
9535 let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
9536 self.close_grant_picker();
9537 return Ok(());
9538 };
9539 self.close_grant_picker();
9540 self.use_grant(picker.grant_instance_id, opt.target_instance_id)
9541 .await?;
9542 self.state
9543 .push_log(format!("Applying {} onto {}…", picker.effect_id, opt.label));
9544 Ok(())
9545 }
9546
9547 pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
9551 let Some(row) = self.state.inventory_selected_row() else {
9552 anyhow::bail!("inventory empty");
9553 };
9554 if row.is_equip_shell {
9555 anyhow::bail!("this is a worn bag — press Enter to unequip it");
9556 }
9557 if row.is_chest_shell {
9558 return self.open_chest_pickup_picker();
9559 }
9560 let Some(instance_id) = row.stack.item_instance_id else {
9561 anyhow::bail!("item has no instance id");
9562 };
9563 let mut options = self.state.move_destinations_for(
9564 &row.from,
9565 row.from_parent_instance_id,
9566 row.stack.item_instance_id,
9567 &row.stack.template_id,
9568 );
9569 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
9570 let category = self.state.inventory_item_category(&row.stack.template_id);
9571 if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
9572 if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
9573 options.insert(
9574 0,
9575 MoveOption::action(
9576 "Sell plot to crown…",
9577 MoveOptionKind::SellPlotToCrown { plot_id },
9578 ),
9579 );
9580 }
9581 }
9582 if on_person && category == Some("consumable") {
9583 if GameState::stack_is_item_grant(&row.stack) {
9584 options.insert(
9585 0,
9586 MoveOption::action("Apply onto gear…", MoveOptionKind::GrantApply),
9587 );
9588 } else {
9589 let study = GameState::stack_is_blueprint_scroll(&row.stack);
9590 options.insert(
9591 0,
9592 MoveOption::action(
9593 if study { "Study" } else { "Use (eat / drink)" },
9594 MoveOptionKind::Use,
9595 ),
9596 );
9597 }
9598 } else if on_person && GameState::stack_is_serving(&row.stack) {
9599 let label = if GameState::stack_is_food_serving(&row.stack) {
9600 "Use (eat)"
9601 } else {
9602 "Use (fill / drink)"
9603 };
9604 options.insert(0, MoveOption::action(label, MoveOptionKind::Use));
9605 }
9606 let item_label = row
9607 .stack
9608 .display_name
9609 .clone()
9610 .unwrap_or_else(|| row.stack.template_id.clone());
9611 let initial_qty = if row.stack.quantity > 1 {
9614 1
9615 } else {
9616 row.stack.quantity
9617 };
9618 self.state.move_picker = Some(MovePicker {
9619 item_instance_id: instance_id,
9620 from: row.from,
9621 item_label,
9622 template_id: row.stack.template_id.clone(),
9623 stack_quantity: row.stack.quantity,
9624 quantity: initial_qty.max(1),
9625 options,
9626 filter: String::new(),
9627 filter_focused: false,
9628 });
9629 self.state.move_picker_index = 0;
9630 self.state.show_move_picker = true;
9631 self.state.show_destroy_picker = false;
9632 self.state.destroy_confirm_pending = false;
9633 self.state.destroy_picker = None;
9634 self.state.show_deconstruct_picker = false;
9635 self.state.deconstruct_confirm_pending = false;
9636 self.state.deconstruct_picker = None;
9637 self.state.clamp_move_picker_quantity();
9638 Ok(())
9639 }
9640
9641 pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
9643 let Some(row) = self.state.inventory_selected_row() else {
9644 anyhow::bail!("inventory empty");
9645 };
9646 if !row.is_chest_shell {
9647 anyhow::bail!("not a placed chest");
9648 }
9649 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
9650 anyhow::bail!("not a placed chest");
9651 };
9652 let Some(instance_id) = row.stack.item_instance_id else {
9653 anyhow::bail!("chest has no instance id");
9654 };
9655 let chest = self
9656 .state
9657 .placed_containers
9658 .iter()
9659 .find(|c| c.id == *container_id)
9660 .cloned()
9661 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
9662 let (px, py) = self.state.player_position();
9663 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
9664 anyhow::bail!("too far from {}", chest.display_name);
9665 }
9666 if chest.locked && !chest.accessible {
9667 anyhow::bail!(
9668 "need the matching key for {} before picking it up",
9669 chest.display_name
9670 );
9671 }
9672 let options = self.state.chest_pickup_destinations(container_id);
9673 let item_label = row
9674 .stack
9675 .display_name
9676 .clone()
9677 .unwrap_or_else(|| row.stack.template_id.clone());
9678 self.state.move_picker = Some(MovePicker {
9679 item_instance_id: instance_id,
9680 from: row.from.clone(),
9681 item_label,
9682 template_id: row.stack.template_id.clone(),
9683 stack_quantity: 1,
9684 quantity: 1,
9685 options,
9686 filter: String::new(),
9687 filter_focused: false,
9688 });
9689 self.state.move_picker_index = 0;
9690 self.state.show_move_picker = true;
9691 self.state.show_destroy_picker = false;
9692 self.state.destroy_confirm_pending = false;
9693 self.state.destroy_picker = None;
9694 self.state.show_deconstruct_picker = false;
9695 self.state.deconstruct_confirm_pending = false;
9696 self.state.deconstruct_picker = None;
9697 Ok(())
9698 }
9699
9700 pub fn close_move_picker(&mut self) {
9701 self.state.show_move_picker = false;
9702 self.state.move_picker = None;
9703 }
9704
9705 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
9706 self.state.move_picker_adjust_quantity(delta);
9707 }
9708
9709 pub fn move_picker_set_quantity_max(&mut self) {
9710 self.state.move_picker_set_quantity_max();
9711 }
9712
9713 pub fn move_picker_set_quantity_min(&mut self) {
9714 self.state.move_picker_set_quantity_min();
9715 }
9716
9717 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
9718 self.state.destroy_picker_adjust_quantity(delta);
9719 }
9720
9721 pub fn destroy_picker_set_quantity_max(&mut self) {
9722 self.state.destroy_picker_set_quantity_max();
9723 }
9724
9725 pub fn destroy_picker_set_quantity_min(&mut self) {
9726 self.state.destroy_picker_set_quantity_min();
9727 }
9728
9729 async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
9730 let Some(picker) = self.state.move_picker.clone() else {
9731 self.close_move_picker();
9732 return Ok(());
9733 };
9734 let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
9735 self.close_move_picker();
9736 return Ok(());
9737 };
9738 match option.kind {
9739 MoveOptionKind::Cancel => {
9740 self.close_move_picker();
9741 }
9742 MoveOptionKind::Use => {
9743 self.close_move_picker();
9744 self.use_item(&picker.template_id).await?;
9745 }
9746 MoveOptionKind::GrantApply => {
9747 self.close_move_picker();
9748 self.open_grant_target_picker()?;
9749 }
9750 MoveOptionKind::SellPlotToCrown { plot_id } => {
9751 self.close_move_picker();
9752 self.confirm_sell_plot_to_crown(plot_id).await?;
9753 }
9754 MoveOptionKind::RelocatePlaced { container_id } => {
9755 self.close_move_picker();
9756 self.state.show_inventory_menu = false;
9757 self.begin_relocate_container(&container_id)?;
9758 }
9759 MoveOptionKind::Drop => {
9760 self.close_move_picker();
9761 if self
9762 .state
9763 .hand_equipped_instance_ids()
9764 .contains(&picker.item_instance_id)
9765 {
9766 anyhow::bail!("unequip that item first");
9767 }
9768 if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
9769 if self.state.deed_bound(&stack) {
9770 anyhow::bail!(
9771 "cannot drop a property deed — store it or trade it to another player"
9772 );
9773 }
9774 if self.state.key_drop_blocked(&stack) {
9775 anyhow::bail!("cannot drop the key while its chest is locked");
9776 }
9777 }
9778 self.drop_item(picker.item_instance_id, picker.from).await?;
9779 self.state
9780 .push_log(format!("Dropped {}", picker.item_label));
9781 }
9782 MoveOptionKind::PickupPlaced {
9783 container_id,
9784 nest_location,
9785 nest_parent_instance_id,
9786 } => {
9787 self.close_move_picker();
9788 self.pickup_container(container_id.clone()).await?;
9789 let nest_into_bag = nest_parent_instance_id.is_some()
9790 || !matches!(nest_location, flatland_protocol::InventoryLocation::Root);
9791 if nest_into_bag {
9792 self.move_item(
9793 picker.item_instance_id,
9794 flatland_protocol::InventoryLocation::Root,
9795 nest_location,
9796 nest_parent_instance_id,
9797 None,
9798 )
9799 .await?;
9800 self.state
9801 .push_log(format!("Picked up {} into bag", picker.item_label));
9802 } else {
9803 self.state
9804 .push_log(format!("Picked up {}", picker.item_label));
9805 }
9806 }
9807 MoveOptionKind::Move {
9808 location,
9809 parent_instance_id,
9810 } => {
9811 self.close_move_picker();
9812 let qty = if picker.quantity >= picker.stack_quantity {
9813 None
9814 } else {
9815 Some(picker.quantity)
9816 };
9817 self.move_item(
9818 picker.item_instance_id,
9819 picker.from,
9820 location,
9821 parent_instance_id,
9822 qty,
9823 )
9824 .await?;
9825 let moved = qty.unwrap_or(picker.stack_quantity);
9826 if moved >= picker.stack_quantity {
9827 self.state.push_log(format!("Moved {}", picker.item_label));
9828 } else {
9829 self.state.push_log(format!(
9830 "Moved {} ×{} of {}",
9831 picker.item_label, moved, picker.stack_quantity
9832 ));
9833 }
9834 }
9835 }
9836 Ok(())
9837 }
9838
9839 pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
9843 let Some(row) = self.state.inventory_selected_row() else {
9844 anyhow::bail!("inventory empty");
9845 };
9846 if row.is_equip_shell {
9847 anyhow::bail!("unequip the bag first (Enter), then drop from your person");
9848 }
9849 if row.is_chest_shell {
9850 anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
9851 }
9852 let Some(inst) = row.stack.item_instance_id else {
9853 anyhow::bail!("item has no instance id");
9854 };
9855 if self.state.hand_equipped_instance_ids().contains(&inst) {
9856 anyhow::bail!("unequip that item first");
9857 }
9858 if self.state.deed_bound(&row.stack) {
9859 anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
9860 }
9861 if self.state.key_drop_blocked(&row.stack) {
9862 anyhow::bail!("cannot drop the key while its chest is locked");
9863 }
9864 let label = row
9865 .stack
9866 .display_name
9867 .clone()
9868 .unwrap_or_else(|| row.stack.template_id.clone());
9869 let placeable = row.stack.world_placeable == Some(true)
9870 || row.from == flatland_protocol::InventoryLocation::Root
9871 && matches!(
9872 self.state
9873 .inventory_item_category(&row.stack.template_id)
9874 .as_deref(),
9875 Some("lodging")
9876 );
9877 if placeable && row.from == flatland_protocol::InventoryLocation::Root {
9878 self.place_container(inst).await?;
9879 self.state.push_log(format!("Placed {label}"));
9880 return Ok(());
9881 }
9882 self.drop_item(inst, row.from).await?;
9883 self.state.push_log(format!("Dropped {label}"));
9884 Ok(())
9885 }
9886
9887 pub async fn drop_item(
9888 &mut self,
9889 item_instance_id: uuid::Uuid,
9890 from: flatland_protocol::InventoryLocation,
9891 ) -> anyhow::Result<()> {
9892 self.seq += 1;
9893 self.session
9894 .submit_intent(Intent::DropItem {
9895 entity_id: self.state.entity_id,
9896 item_instance_id,
9897 from,
9898 seq: self.seq,
9899 })
9900 .await?;
9901 self.state.intents_sent += 1;
9902 Ok(())
9903 }
9904
9905 pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
9907 let Some(row) = self.state.inventory_selected_row() else {
9908 anyhow::bail!("inventory empty");
9909 };
9910 if row.is_equip_shell {
9911 anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
9912 }
9913 if row.is_chest_shell {
9914 anyhow::bail!("can't destroy a placed chest from the inventory list");
9915 }
9916 let Some(instance_id) = row.stack.item_instance_id else {
9917 anyhow::bail!("item has no instance id");
9918 };
9919 if self
9920 .state
9921 .hand_equipped_instance_ids()
9922 .contains(&instance_id)
9923 {
9924 anyhow::bail!("unequip that item first");
9925 }
9926 if self.state.deed_bound(&row.stack) {
9927 anyhow::bail!(
9928 "cannot destroy a property deed — store it or trade it to another player"
9929 );
9930 }
9931 if self.state.key_drop_blocked(&row.stack) {
9932 anyhow::bail!("cannot destroy the key while its chest is locked");
9933 }
9934 let item_label = row
9935 .stack
9936 .display_name
9937 .clone()
9938 .unwrap_or_else(|| row.stack.template_id.clone());
9939 self.state.destroy_picker = Some(DestroyPicker {
9940 item_instance_id: instance_id,
9941 from: row.from,
9942 item_label,
9943 stack_quantity: row.stack.quantity,
9944 quantity: row.stack.quantity,
9945 });
9946 self.state.destroy_confirm_pending = false;
9947 self.state.show_destroy_picker = true;
9948 self.state.show_move_picker = false;
9949 self.state.move_picker = None;
9950 self.close_deconstruct_picker();
9951 Ok(())
9952 }
9953
9954 pub fn close_destroy_picker(&mut self) {
9955 self.state.show_destroy_picker = false;
9956 self.state.destroy_confirm_pending = false;
9957 self.state.destroy_picker = None;
9958 }
9959
9960 pub fn cancel_destroy_confirm(&mut self) {
9961 self.state.destroy_confirm_pending = false;
9962 }
9963
9964 pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
9965 if self.state.destroy_picker.is_none() {
9966 self.close_destroy_picker();
9967 return Ok(());
9968 }
9969 self.state.destroy_confirm_pending = true;
9970 Ok(())
9971 }
9972
9973 pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
9974 let Some(picker) = self.state.destroy_picker.clone() else {
9975 self.close_destroy_picker();
9976 return Ok(());
9977 };
9978 let qty = if picker.quantity >= picker.stack_quantity {
9979 None
9980 } else {
9981 Some(picker.quantity)
9982 };
9983 self.destroy_item(picker.item_instance_id, picker.from, qty)
9984 .await?;
9985 let destroyed = qty.unwrap_or(picker.stack_quantity);
9986 if destroyed >= picker.stack_quantity {
9987 self.state
9988 .push_log(format!("Destroyed {}", picker.item_label));
9989 } else {
9990 self.state.push_log(format!(
9991 "Destroyed {} ×{} of {}",
9992 picker.item_label, destroyed, picker.stack_quantity
9993 ));
9994 }
9995 self.close_destroy_picker();
9996 Ok(())
9997 }
9998
9999 pub fn open_deconstruct_picker(&mut self) -> anyhow::Result<()> {
10001 let Some(row) = self.state.inventory_selected_row() else {
10002 anyhow::bail!("inventory empty");
10003 };
10004 if row.is_equip_shell {
10005 anyhow::bail!("unequip the bag first (Enter), then deconstruct from your person");
10006 }
10007 if row.is_chest_shell {
10008 anyhow::bail!("pick up the chest first, then deconstruct it from your inventory");
10009 }
10010 if row.from != flatland_protocol::InventoryLocation::Root {
10011 anyhow::bail!("hold the item in your inventory to deconstruct it");
10012 }
10013 let Some(instance_id) = row.stack.item_instance_id else {
10014 anyhow::bail!("item has no instance id");
10015 };
10016 if self
10017 .state
10018 .hand_equipped_instance_ids()
10019 .contains(&instance_id)
10020 {
10021 anyhow::bail!("unequip that item first");
10022 }
10023 if self.state.deed_bound(&row.stack) {
10024 anyhow::bail!(
10025 "cannot deconstruct a property deed — store it or trade it to another player"
10026 );
10027 }
10028 if !GameState::stack_is_deconstructable(&row.stack) {
10029 anyhow::bail!("only crafted items can be deconstructed");
10030 }
10031 let item_label = row
10032 .stack
10033 .display_name
10034 .clone()
10035 .unwrap_or_else(|| row.stack.template_id.clone());
10036 self.state.deconstruct_picker = Some(DestroyPicker {
10037 item_instance_id: instance_id,
10038 from: row.from,
10039 item_label,
10040 stack_quantity: row.stack.quantity,
10041 quantity: 1,
10042 });
10043 self.state.deconstruct_confirm_pending = false;
10044 self.state.show_deconstruct_picker = true;
10045 self.state.show_move_picker = false;
10046 self.state.move_picker = None;
10047 self.close_destroy_picker();
10048 Ok(())
10049 }
10050
10051 pub fn close_deconstruct_picker(&mut self) {
10052 self.state.show_deconstruct_picker = false;
10053 self.state.deconstruct_confirm_pending = false;
10054 self.state.deconstruct_picker = None;
10055 }
10056
10057 pub fn cancel_deconstruct_confirm(&mut self) {
10058 self.state.deconstruct_confirm_pending = false;
10059 }
10060
10061 pub fn request_deconstruct_confirm(&mut self) -> anyhow::Result<()> {
10062 if self.state.deconstruct_picker.is_none() {
10063 self.close_deconstruct_picker();
10064 return Ok(());
10065 }
10066 self.state.deconstruct_confirm_pending = true;
10067 Ok(())
10068 }
10069
10070 pub fn deconstruct_picker_adjust_quantity(&mut self, delta: i32) {
10071 self.state.deconstruct_picker_adjust_quantity(delta);
10072 }
10073
10074 pub fn deconstruct_picker_set_quantity_max(&mut self) {
10075 self.state.deconstruct_picker_set_quantity_max();
10076 }
10077
10078 pub fn deconstruct_picker_set_quantity_min(&mut self) {
10079 self.state.deconstruct_picker_set_quantity_min();
10080 }
10081
10082 pub async fn confirm_deconstruct_item(&mut self) -> anyhow::Result<()> {
10083 let Some(picker) = self.state.deconstruct_picker.clone() else {
10084 self.close_deconstruct_picker();
10085 return Ok(());
10086 };
10087 let qty = if picker.quantity >= picker.stack_quantity {
10088 None
10089 } else {
10090 Some(picker.quantity)
10091 };
10092 self.deconstruct_item(picker.item_instance_id, picker.from, qty)
10093 .await?;
10094 let n = qty.unwrap_or(picker.stack_quantity);
10095 if n >= picker.stack_quantity {
10096 self.state
10097 .push_log(format!("Deconstructing {}", picker.item_label));
10098 } else {
10099 self.state.push_log(format!(
10100 "Deconstructing {} ×{} of {}",
10101 picker.item_label, n, picker.stack_quantity
10102 ));
10103 }
10104 self.close_deconstruct_picker();
10105 Ok(())
10106 }
10107
10108 pub async fn deconstruct_item(
10109 &mut self,
10110 item_instance_id: uuid::Uuid,
10111 from: flatland_protocol::InventoryLocation,
10112 quantity: Option<u32>,
10113 ) -> anyhow::Result<()> {
10114 self.seq += 1;
10115 self.session
10116 .submit_intent(Intent::DeconstructItem {
10117 entity_id: self.state.entity_id,
10118 item_instance_id,
10119 from,
10120 quantity,
10121 seq: self.seq,
10122 })
10123 .await?;
10124 self.state.intents_sent += 1;
10125 Ok(())
10126 }
10127
10128 pub async fn destroy_item(
10129 &mut self,
10130 item_instance_id: uuid::Uuid,
10131 from: flatland_protocol::InventoryLocation,
10132 quantity: Option<u32>,
10133 ) -> anyhow::Result<()> {
10134 self.seq += 1;
10135 self.session
10136 .submit_intent(Intent::DestroyItem {
10137 entity_id: self.state.entity_id,
10138 item_instance_id,
10139 from,
10140 quantity,
10141 seq: self.seq,
10142 })
10143 .await?;
10144 self.state.intents_sent += 1;
10145 Ok(())
10146 }
10147
10148 pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
10150 if let Some(row) = self.state.inventory_selected_row() {
10151 if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
10152 return self.toggle_placed_chest_lock(container_id).await;
10153 }
10154 }
10155 self.toggle_nearby_chest_lock().await
10156 }
10157
10158 pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
10159 let chest = self
10160 .state
10161 .placed_containers
10162 .iter()
10163 .find(|c| c.id == container_id)
10164 .cloned()
10165 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
10166 let (px, py) = self.state.player_position();
10167 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
10168 anyhow::bail!("too far from {}", chest.display_name);
10169 }
10170 if !chest.accessible && chest.locked {
10171 anyhow::bail!(
10172 "need the matching key for {} (each crafted chest has its own key)",
10173 chest.display_name
10174 );
10175 }
10176 let lock = !chest.locked;
10177 self.set_container_locked(
10178 flatland_protocol::InventoryLocation::Placed {
10179 container_id: chest.id.clone(),
10180 },
10181 lock,
10182 )
10183 .await?;
10184 self.state.push_log(if lock {
10185 format!("Locked {}", chest.display_name)
10186 } else {
10187 format!("Unlocked {}", chest.display_name)
10188 });
10189 Ok(())
10190 }
10191
10192 pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
10194 let chest = self
10195 .state
10196 .nearest_placed_container(CONTAINER_RANGE_M)
10197 .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
10198 self.toggle_placed_chest_lock(&chest.id).await
10199 }
10200
10201 pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
10202 self.equip_mainhand(None).await
10203 }
10204
10205 pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
10206 if !self.state.is_alive() {
10207 anyhow::bail!("you are dead");
10208 }
10209 self.seq += 1;
10210 self.session
10211 .submit_intent(Intent::EquipOffhand {
10212 entity_id: self.state.entity_id,
10213 template_id,
10214 instance_id: None,
10215 seq: self.seq,
10216 })
10217 .await?;
10218 self.state.intents_sent += 1;
10219 Ok(())
10220 }
10221
10222 pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
10223 self.equip_offhand(None).await
10224 }
10225
10226 pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
10227 let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
10228 for slot in slots {
10229 self.equip_worn(slot, None).await?;
10230 }
10231 Ok(())
10232 }
10233
10234 pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
10235 let (px, py) = self.state.player_position();
10236 let in_range: Vec<_> = self
10237 .state
10238 .placed_containers
10239 .iter()
10240 .filter(|c| self.state.placed_container_in_current_space(c))
10241 .filter(|c| (c.x - px).hypot(c.y - py) <= 2.0)
10242 .collect();
10243 let nearest_free = in_range
10244 .iter()
10245 .copied()
10246 .filter(|c| !self.state.lodging_is_occupied(&c.id))
10247 .min_by(|a, b| {
10248 let da = (a.x - px).hypot(a.y - py);
10249 let db = (b.x - px).hypot(b.y - py);
10250 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
10251 })
10252 .cloned();
10253 if let Some(chest) = nearest_free {
10254 return self.pickup_container(chest.id).await;
10255 }
10256 if in_range
10257 .iter()
10258 .any(|c| self.state.lodging_is_occupied(&c.id))
10259 {
10260 anyhow::bail!("dismiss or reassign workers before picking up lodging");
10261 }
10262 if in_range.is_empty() {
10263 anyhow::bail!("no chest nearby");
10264 }
10265 anyhow::bail!("too far from chest");
10266 }
10267
10268 pub async fn equip_worn(
10269 &mut self,
10270 slot: BodySlot,
10271 instance_id: Option<uuid::Uuid>,
10272 ) -> anyhow::Result<()> {
10273 self.seq += 1;
10274 self.session
10275 .submit_intent(Intent::EquipWorn {
10276 entity_id: self.state.entity_id,
10277 slot,
10278 instance_id,
10279 seq: self.seq,
10280 })
10281 .await?;
10282 self.state.intents_sent += 1;
10283 Ok(())
10284 }
10285
10286 pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
10287 self.seq += 1;
10288 self.session
10289 .submit_intent(Intent::PlaceContainer {
10290 entity_id: self.state.entity_id,
10291 item_instance_id,
10292 seq: self.seq,
10293 })
10294 .await?;
10295 self.state.intents_sent += 1;
10296 Ok(())
10297 }
10298
10299 pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
10300 self.seq += 1;
10301 self.session
10302 .submit_intent(Intent::PickupContainer {
10303 entity_id: self.state.entity_id,
10304 container_id,
10305 seq: self.seq,
10306 })
10307 .await?;
10308 self.state.intents_sent += 1;
10309 Ok(())
10310 }
10311
10312 pub async fn move_item(
10313 &mut self,
10314 item_instance_id: uuid::Uuid,
10315 from: flatland_protocol::InventoryLocation,
10316 to: flatland_protocol::InventoryLocation,
10317 to_parent_instance_id: Option<uuid::Uuid>,
10318 quantity: Option<u32>,
10319 ) -> anyhow::Result<()> {
10320 self.seq += 1;
10321 self.session
10322 .submit_intent(Intent::MoveItem {
10323 entity_id: self.state.entity_id,
10324 item_instance_id,
10325 from,
10326 to,
10327 to_parent_instance_id,
10328 quantity,
10329 seq: self.seq,
10330 })
10331 .await?;
10332 self.state.intents_sent += 1;
10333 Ok(())
10334 }
10335
10336 pub async fn set_container_locked(
10337 &mut self,
10338 location: flatland_protocol::InventoryLocation,
10339 locked: bool,
10340 ) -> anyhow::Result<()> {
10341 self.seq += 1;
10342 self.session
10343 .submit_intent(Intent::SetContainerLocked {
10344 entity_id: self.state.entity_id,
10345 location,
10346 locked,
10347 seq: self.seq,
10348 })
10349 .await?;
10350 self.state.intents_sent += 1;
10351 Ok(())
10352 }
10353
10354 pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
10355 if !self.state.is_alive() {
10356 anyhow::bail!("you are dead");
10357 }
10358 self.seq += 1;
10359 self.session
10360 .submit_intent(Intent::Use {
10361 entity_id: self.state.entity_id,
10362 template_id: template_id.to_string(),
10363 seq: self.seq,
10364 })
10365 .await?;
10366 self.state.intents_sent += 1;
10367 Ok(())
10368 }
10369
10370 pub async fn use_grant(
10372 &mut self,
10373 grant_instance_id: uuid::Uuid,
10374 target_instance_id: uuid::Uuid,
10375 ) -> anyhow::Result<()> {
10376 if !self.state.is_alive() {
10377 anyhow::bail!("you are dead");
10378 }
10379 self.seq += 1;
10380 self.session
10381 .submit_intent(Intent::UseGrant {
10382 entity_id: self.state.entity_id,
10383 grant_instance_id,
10384 target_instance_id,
10385 seq: self.seq,
10386 })
10387 .await?;
10388 self.state.intents_sent += 1;
10389 Ok(())
10390 }
10391
10392 pub fn open_craft_menu(&mut self) {
10393 self.state.show_craft_menu = true;
10394 self.state.show_shop_menu = false;
10395 self.state.shop_catalog = None;
10396 self.state.show_stats = false;
10397 self.state.show_inventory_menu = false;
10398 self.state.reload_craft_prefs();
10399 self.state.craft_tab = CraftTab::Ready;
10400 self.state.craft_filter.clear();
10401 self.state.craft_filter_focused = false;
10402 self.state.craft_menu_index = 0;
10403 self.state.clamp_craft_menu_index();
10404 self.state.craft_batch_quantity = 1;
10405 self.state.clamp_craft_batch_quantity();
10406 }
10407
10408 pub fn close_craft_menu(&mut self) {
10409 self.state.show_craft_menu = false;
10410 self.state.craft_filter_focused = false;
10411 }
10412
10413 pub fn toggle_keychain_menu(&mut self) {
10414 if self.state.show_keychain_menu {
10415 self.close_keychain_menu();
10416 } else {
10417 self.state.show_keychain_menu = true;
10418 self.state.show_craft_menu = false;
10419 self.state.show_shop_menu = false;
10420 self.state.show_inventory_menu = false;
10421 let n = self.state.keychain_entries().len();
10422 if n == 0 {
10423 self.state.keychain_menu_index = 0;
10424 } else {
10425 self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
10426 }
10427 }
10428 }
10429
10430 pub fn close_keychain_menu(&mut self) {
10431 self.state.show_keychain_menu = false;
10432 }
10433
10434 pub fn keychain_menu_move(&mut self, delta: i32) {
10435 let n = self.state.keychain_entries().len();
10436 if n == 0 {
10437 self.state.keychain_menu_index = 0;
10438 return;
10439 }
10440 let idx = self.state.keychain_menu_index as i32 + delta;
10441 self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
10442 }
10443
10444 pub fn keychain_menu_page(&mut self, pages: i32) {
10445 let n = self.state.keychain_entries().len();
10446 self.state.keychain_menu_index = page_list_index(self.state.keychain_menu_index, pages, n);
10447 }
10448
10449 pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
10450 if !self.state.is_alive() {
10451 anyhow::bail!("you are dead");
10452 }
10453 let entries = self.state.keychain_entries();
10454 let Some(entry) = entries.get(self.state.keychain_menu_index) else {
10455 anyhow::bail!("nothing selected");
10456 };
10457 let Some(instance_id) = entry.stack.item_instance_id else {
10458 anyhow::bail!("key has no instance id");
10459 };
10460 if entry.stowed {
10461 self.move_item(
10462 instance_id,
10463 flatland_protocol::InventoryLocation::Keychain,
10464 flatland_protocol::InventoryLocation::Root,
10465 None,
10466 Some(1),
10467 )
10468 .await
10469 } else {
10470 self.move_item(
10471 instance_id,
10472 flatland_protocol::InventoryLocation::Root,
10473 flatland_protocol::InventoryLocation::Keychain,
10474 None,
10475 Some(1),
10476 )
10477 .await
10478 }
10479 }
10480
10481 pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
10482 let npc_id = self.state.shop_catalog.as_ref().map(|c| c.npc_id.clone());
10483 self.state.show_shop_menu = false;
10484 self.state.shop_catalog = None;
10485 self.state.clear_shop_trade_log();
10486 if let Some(npc_id) = npc_id {
10487 self.seq += 1;
10488 self.session
10489 .submit_intent(Intent::ShopClose {
10490 entity_id: self.state.entity_id,
10491 npc_id,
10492 seq: self.seq,
10493 })
10494 .await?;
10495 self.state.intents_sent += 1;
10496 }
10497 Ok(())
10498 }
10499
10500 pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
10501 let Some(panel) = self.state.bank_panel.clone() else {
10502 return Ok(());
10503 };
10504 self.seq += 1;
10505 self.session
10506 .submit_intent(Intent::BankDeposit {
10507 entity_id: self.state.entity_id,
10508 npc_id: panel.npc_id,
10509 amount_copper,
10510 seq: self.seq,
10511 })
10512 .await?;
10513 self.state.intents_sent += 1;
10514 Ok(())
10515 }
10516
10517 pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
10518 let Some(panel) = self.state.bank_panel.clone() else {
10519 return Ok(());
10520 };
10521 self.seq += 1;
10522 self.session
10523 .submit_intent(Intent::BankWithdraw {
10524 entity_id: self.state.entity_id,
10525 npc_id: panel.npc_id,
10526 amount_copper,
10527 seq: self.seq,
10528 })
10529 .await?;
10530 self.state.intents_sent += 1;
10531 Ok(())
10532 }
10533
10534 pub async fn bank_transfer(
10535 &mut self,
10536 to_character_id: Option<uuid::Uuid>,
10537 to_name: String,
10538 amount_copper: u64,
10539 ) -> anyhow::Result<()> {
10540 let Some(panel) = self.state.bank_panel.clone() else {
10541 return Ok(());
10542 };
10543 self.seq += 1;
10544 self.session
10545 .submit_intent(Intent::BankTransfer {
10546 entity_id: self.state.entity_id,
10547 npc_id: panel.npc_id,
10548 to_character_id,
10549 to_name,
10550 amount_copper,
10551 seq: self.seq,
10552 })
10553 .await?;
10554 self.state.intents_sent += 1;
10555 Ok(())
10556 }
10557
10558 pub fn bank_menu_move(&mut self, delta: i32) {
10559 let n = self.state.bank_menu_options().len();
10560 if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
10561 return;
10562 }
10563 let idx = self.state.bank_menu_index as i32;
10564 self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10565 }
10566
10567 pub fn storage_menu_move(&mut self, delta: i32) {
10568 let n = self.state.storage_menu_options().len();
10569 if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
10570 return;
10571 }
10572 let idx = self.state.storage_menu_index as i32;
10573 self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10574 }
10575
10576 pub fn storage_pick_move(&mut self, delta: i32) {
10577 let n = match &self.state.storage_ui_mode {
10578 StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
10579 StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
10580 self.state.storage_vault_options().len()
10581 }
10582 StorageUiMode::Menu
10583 | StorageUiMode::StoreAmount { .. }
10584 | StorageUiMode::TakeAmount { .. }
10585 | StorageUiMode::ShipAmount { .. } => 0,
10586 };
10587 if n == 0 {
10588 return;
10589 }
10590 match &mut self.state.storage_ui_mode {
10591 StorageUiMode::StorePick { index }
10592 | StorageUiMode::TakePick { index }
10593 | StorageUiMode::ShipPick { index, .. } => {
10594 *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
10595 }
10596 StorageUiMode::Menu
10597 | StorageUiMode::StoreAmount { .. }
10598 | StorageUiMode::TakeAmount { .. }
10599 | StorageUiMode::ShipAmount { .. } => {}
10600 }
10601 }
10602
10603 pub fn storage_ui_back(&mut self) {
10604 self.state.storage_ui_mode = match &self.state.storage_ui_mode {
10605 StorageUiMode::StoreAmount { pick_index, .. } => {
10606 StorageUiMode::StorePick { index: *pick_index }
10607 }
10608 StorageUiMode::TakeAmount { pick_index, .. } => {
10609 StorageUiMode::TakePick { index: *pick_index }
10610 }
10611 StorageUiMode::ShipAmount {
10612 dest_building_id,
10613 dest_label,
10614 pick_index,
10615 ..
10616 } => StorageUiMode::ShipPick {
10617 dest_building_id: dest_building_id.clone(),
10618 dest_label: dest_label.clone(),
10619 index: *pick_index,
10620 },
10621 StorageUiMode::StorePick { .. }
10622 | StorageUiMode::TakePick { .. }
10623 | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
10624 StorageUiMode::Menu => StorageUiMode::Menu,
10625 };
10626 }
10627
10628 pub fn storage_amount_append_char(&mut self, c: char) {
10629 match &mut self.state.storage_ui_mode {
10630 StorageUiMode::StoreAmount { input, .. }
10631 | StorageUiMode::TakeAmount { input, .. }
10632 | StorageUiMode::ShipAmount { input, .. } => {
10633 if c.is_ascii_digit() && input.len() < 8 {
10634 input.push(c);
10635 }
10636 }
10637 _ => {}
10638 }
10639 }
10640
10641 pub fn storage_amount_backspace(&mut self) {
10642 match &mut self.state.storage_ui_mode {
10643 StorageUiMode::StoreAmount { input, .. }
10644 | StorageUiMode::TakeAmount { input, .. }
10645 | StorageUiMode::ShipAmount { input, .. } => {
10646 input.pop();
10647 }
10648 _ => {}
10649 }
10650 }
10651
10652 pub fn storage_ui_typing(&self) -> bool {
10653 matches!(
10654 self.state.storage_ui_mode,
10655 StorageUiMode::StoreAmount { .. }
10656 | StorageUiMode::TakeAmount { .. }
10657 | StorageUiMode::ShipAmount { .. }
10658 )
10659 }
10660
10661 pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
10662 match self.state.storage_ui_mode.clone() {
10663 StorageUiMode::Menu => {
10664 let index = self.state.storage_menu_index;
10665 match index {
10666 0 => {
10667 let opts = self.state.storage_store_options();
10668 if opts.is_empty() {
10669 self.state.push_log("Nothing loose to store.");
10670 return Ok(());
10671 }
10672 self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
10673 }
10674 1 => {
10675 let opts = self.state.storage_vault_options();
10676 if opts.is_empty() {
10677 self.state.push_log("Vault is empty.");
10678 return Ok(());
10679 }
10680 self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
10681 }
10682 n => {
10683 let dest = self
10684 .state
10685 .storage_panel
10686 .as_ref()
10687 .and_then(|p| p.ship_destinations.get(n - 2))
10688 .cloned();
10689 let Some(dest) = dest else {
10690 return Ok(());
10691 };
10692 let opts = self.state.storage_vault_options();
10693 if opts.is_empty() {
10694 self.state.push_log("Vault is empty — nothing to ship.");
10695 return Ok(());
10696 }
10697 self.state.storage_ui_mode = StorageUiMode::ShipPick {
10698 dest_building_id: dest.building_id,
10699 dest_label: dest.label,
10700 index: 0,
10701 };
10702 }
10703 }
10704 }
10705 StorageUiMode::StorePick { index } => {
10706 let opts = self.state.storage_store_options();
10707 let Some(opt) = opts.get(index) else {
10708 self.state.push_log("Nothing loose to store.");
10709 self.state.storage_ui_mode = StorageUiMode::Menu;
10710 return Ok(());
10711 };
10712 self.state.storage_ui_mode = StorageUiMode::StoreAmount {
10713 pick_index: index,
10714 item_instance_id: opt.item_instance_id,
10715 label: opt.label.clone(),
10716 max_qty: opt.quantity.max(1),
10717 input: String::new(),
10718 };
10719 }
10720 StorageUiMode::TakePick { index } => {
10721 let opts = self.state.storage_vault_options();
10722 let Some(opt) = opts.get(index) else {
10723 self.state.push_log("Vault is empty.");
10724 self.state.storage_ui_mode = StorageUiMode::Menu;
10725 return Ok(());
10726 };
10727 self.state.storage_ui_mode = StorageUiMode::TakeAmount {
10728 pick_index: index,
10729 item_instance_id: opt.item_instance_id,
10730 label: opt.label.clone(),
10731 max_qty: opt.quantity.max(1),
10732 input: String::new(),
10733 };
10734 }
10735 StorageUiMode::ShipPick {
10736 dest_building_id,
10737 dest_label,
10738 index,
10739 } => {
10740 let opts = self.state.storage_vault_options();
10741 let Some(opt) = opts.get(index) else {
10742 self.state.push_log("Vault is empty — nothing to ship.");
10743 self.state.storage_ui_mode = StorageUiMode::Menu;
10744 return Ok(());
10745 };
10746 self.state.storage_ui_mode = StorageUiMode::ShipAmount {
10747 dest_building_id,
10748 dest_label,
10749 pick_index: index,
10750 item_instance_id: opt.item_instance_id,
10751 label: opt.label.clone(),
10752 max_qty: opt.quantity.max(1),
10753 input: String::new(),
10754 };
10755 }
10756 StorageUiMode::StoreAmount {
10757 item_instance_id,
10758 max_qty,
10759 input,
10760 ..
10761 } => {
10762 let Some(qty) = parse_storage_quantity(&input) else {
10763 self.state.push_log("Enter a quantity (blank or 0 = all).");
10764 return Ok(());
10765 };
10766 let qty = qty.map(|n| n.min(max_qty).max(1));
10767 self.storage_store(item_instance_id, qty).await?;
10768 self.state.storage_ui_mode = StorageUiMode::Menu;
10769 }
10770 StorageUiMode::TakeAmount {
10771 item_instance_id,
10772 max_qty,
10773 input,
10774 ..
10775 } => {
10776 let Some(qty) = parse_storage_quantity(&input) else {
10777 self.state.push_log("Enter a quantity (blank or 0 = all).");
10778 return Ok(());
10779 };
10780 let qty = qty.map(|n| n.min(max_qty).max(1));
10781 self.storage_take(item_instance_id, qty).await?;
10782 self.state.storage_ui_mode = StorageUiMode::Menu;
10783 }
10784 StorageUiMode::ShipAmount {
10785 dest_building_id,
10786 item_instance_id,
10787 max_qty,
10788 input,
10789 ..
10790 } => {
10791 let Some(qty) = parse_storage_quantity(&input) else {
10792 self.state.push_log("Enter a quantity (blank or 0 = all).");
10793 return Ok(());
10794 };
10795 let qty = qty.map(|n| n.min(max_qty).max(1));
10796 self.storage_ship(dest_building_id, item_instance_id, qty)
10797 .await?;
10798 self.state.storage_ui_mode = StorageUiMode::Menu;
10799 }
10800 }
10801 Ok(())
10802 }
10803
10804 pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
10805 match self.state.bank_ui_mode.clone() {
10806 BankUiMode::Menu => {
10807 let choice = self
10808 .state
10809 .bank_menu_options()
10810 .get(self.state.bank_menu_index)
10811 .copied()
10812 .unwrap_or("Deposit…");
10813 match choice {
10814 "Withdraw…" => {
10815 self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
10816 input: String::new(),
10817 };
10818 }
10819 "Deposit all" => self.bank_deposit(0).await?,
10820 "Withdraw all" => self.bank_withdraw(0).await?,
10821 "Transfer…" => {
10822 self.state.bank_ui_mode = BankUiMode::TransferName {
10823 input: String::new(),
10824 };
10825 }
10826 _ => {
10827 self.state.bank_ui_mode = BankUiMode::DepositAmount {
10828 input: String::new(),
10829 };
10830 }
10831 }
10832 }
10833 BankUiMode::DepositAmount { input } => {
10834 let Some(amount) = parse_bank_copper_amount(&input) else {
10835 self.state
10836 .push_log("Enter a copper amount (blank or 0 = everything on person).");
10837 return Ok(());
10838 };
10839 self.bank_deposit(amount).await?;
10840 self.state.bank_ui_mode = BankUiMode::Menu;
10841 }
10842 BankUiMode::WithdrawAmount { input } => {
10843 let Some(amount) = parse_bank_copper_amount(&input) else {
10844 self.state
10845 .push_log("Enter a copper amount (blank or 0 = full ledger).");
10846 return Ok(());
10847 };
10848 self.bank_withdraw(amount).await?;
10849 self.state.bank_ui_mode = BankUiMode::Menu;
10850 }
10851 BankUiMode::TransferName { input } => {
10852 let name = input.trim().to_string();
10853 if name.is_empty() {
10854 self.state.push_log("Enter the recipient character name.");
10855 return Ok(());
10856 }
10857 self.state.bank_ui_mode = BankUiMode::TransferAmount {
10858 to_name: name,
10859 input: String::new(),
10860 };
10861 }
10862 BankUiMode::TransferAmount { to_name, input } => {
10863 let amount: u64 = match input.trim().parse() {
10864 Ok(v) if v > 0 => v,
10865 _ => {
10866 self.state
10867 .push_log("Enter a positive copper amount to transfer.");
10868 return Ok(());
10869 }
10870 };
10871 self.bank_transfer(None, to_name, amount).await?;
10872 self.state.bank_ui_mode = BankUiMode::Menu;
10873 }
10874 }
10875 Ok(())
10876 }
10877
10878 pub fn bank_transfer_back(&mut self) {
10879 match &self.state.bank_ui_mode {
10880 BankUiMode::TransferAmount { to_name, .. } => {
10881 self.state.bank_ui_mode = BankUiMode::TransferName {
10882 input: to_name.clone(),
10883 };
10884 }
10885 BankUiMode::TransferName { .. }
10886 | BankUiMode::DepositAmount { .. }
10887 | BankUiMode::WithdrawAmount { .. } => {
10888 self.state.bank_ui_mode = BankUiMode::Menu;
10889 }
10890 BankUiMode::Menu => {}
10891 }
10892 }
10893
10894 pub fn bank_transfer_append_char(&mut self, c: char) {
10895 match &mut self.state.bank_ui_mode {
10896 BankUiMode::TransferName { input } => {
10897 if input.len() < 32 && !c.is_control() {
10898 input.push(c);
10899 }
10900 }
10901 BankUiMode::DepositAmount { input }
10902 | BankUiMode::WithdrawAmount { input }
10903 | BankUiMode::TransferAmount { input, .. } => {
10904 if c.is_ascii_digit() && input.len() < 12 {
10905 input.push(c);
10906 }
10907 }
10908 BankUiMode::Menu => {}
10909 }
10910 }
10911
10912 pub fn bank_transfer_backspace(&mut self) {
10913 match &mut self.state.bank_ui_mode {
10914 BankUiMode::TransferName { input }
10915 | BankUiMode::DepositAmount { input }
10916 | BankUiMode::WithdrawAmount { input }
10917 | BankUiMode::TransferAmount { input, .. } => {
10918 input.pop();
10919 }
10920 BankUiMode::Menu => {}
10921 }
10922 }
10923
10924 pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
10925 let npc_id = self.state.bank_panel.as_ref().map(|p| p.npc_id.clone());
10926 self.state.clear_bank_panel();
10927 if let Some(npc_id) = npc_id {
10928 self.seq += 1;
10929 self.session
10930 .submit_intent(Intent::BankClose {
10931 entity_id: self.state.entity_id,
10932 npc_id,
10933 seq: self.seq,
10934 })
10935 .await?;
10936 self.state.intents_sent += 1;
10937 }
10938 Ok(())
10939 }
10940
10941 pub async fn storage_store(
10942 &mut self,
10943 item_instance_id: uuid::Uuid,
10944 quantity: Option<u32>,
10945 ) -> anyhow::Result<()> {
10946 let Some(panel) = self.state.storage_panel.clone() else {
10947 return Ok(());
10948 };
10949 self.seq += 1;
10950 self.session
10951 .submit_intent(Intent::StorageStore {
10952 entity_id: self.state.entity_id,
10953 npc_id: panel.npc_id,
10954 item_instance_id,
10955 quantity,
10956 seq: self.seq,
10957 })
10958 .await?;
10959 self.state.intents_sent += 1;
10960 Ok(())
10961 }
10962
10963 pub async fn storage_take(
10964 &mut self,
10965 item_instance_id: uuid::Uuid,
10966 quantity: Option<u32>,
10967 ) -> anyhow::Result<()> {
10968 let Some(panel) = self.state.storage_panel.clone() else {
10969 return Ok(());
10970 };
10971 self.seq += 1;
10972 self.session
10973 .submit_intent(Intent::StorageTake {
10974 entity_id: self.state.entity_id,
10975 npc_id: panel.npc_id,
10976 item_instance_id,
10977 quantity,
10978 seq: self.seq,
10979 })
10980 .await?;
10981 self.state.intents_sent += 1;
10982 Ok(())
10983 }
10984
10985 pub async fn storage_ship(
10986 &mut self,
10987 dest_building_id: String,
10988 item_instance_id: uuid::Uuid,
10989 quantity: Option<u32>,
10990 ) -> anyhow::Result<()> {
10991 let Some(panel) = self.state.storage_panel.clone() else {
10992 return Ok(());
10993 };
10994 self.seq += 1;
10995 self.session
10996 .submit_intent(Intent::StorageShip {
10997 entity_id: self.state.entity_id,
10998 npc_id: panel.npc_id,
10999 dest_building_id,
11000 item_instance_id,
11001 quantity,
11002 seq: self.seq,
11003 })
11004 .await?;
11005 self.state.intents_sent += 1;
11006 Ok(())
11007 }
11008
11009 pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
11010 let npc_id = self.state.storage_panel.as_ref().map(|p| p.npc_id.clone());
11011 self.state.clear_storage_panel();
11012 if let Some(npc_id) = npc_id {
11013 self.seq += 1;
11014 self.session
11015 .submit_intent(Intent::StorageClose {
11016 entity_id: self.state.entity_id,
11017 npc_id,
11018 seq: self.seq,
11019 })
11020 .await?;
11021 self.state.intents_sent += 1;
11022 }
11023 Ok(())
11024 }
11025
11026 pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
11027 let npc_id = self.state.market_panel.as_ref().map(|p| p.npc_id.clone());
11028 self.state.clear_market_panel();
11029 if let Some(npc_id) = npc_id {
11030 self.seq += 1;
11031 self.session
11032 .submit_intent(Intent::MarketClose {
11033 entity_id: self.state.entity_id,
11034 npc_id,
11035 seq: self.seq,
11036 })
11037 .await?;
11038 self.state.intents_sent += 1;
11039 }
11040 Ok(())
11041 }
11042
11043 pub fn market_move_selection(&mut self, delta: i32) {
11044 let indices = self.state.market_filtered_listing_indices();
11045 let n = indices.len();
11046 if n == 0 {
11047 self.state.market_menu_index = 0;
11048 return;
11049 }
11050 let cur = self.state.market_menu_index as i32;
11051 self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
11052 }
11053
11054 pub fn market_page_selection(&mut self, pages: i32) {
11055 let indices = self.state.market_filtered_listing_indices();
11056 let n = indices.len();
11057 if n == 0 {
11058 self.state.market_menu_index = 0;
11059 return;
11060 }
11061 self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
11062 }
11063
11064 pub fn market_list_page(&mut self, pages: i32) {
11065 match &self.state.market_ui_mode {
11066 MarketUiMode::ListSource { index } => {
11067 let n = self.state.market_list_source_options().len();
11068 if n == 0 {
11069 return;
11070 }
11071 let next = page_list_index(*index, pages, n);
11072 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
11073 }
11074 MarketUiMode::ListPricingMode { index, .. } => {
11075 let next = page_list_index(*index, pages, 2);
11076 if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
11077 {
11078 *index = next;
11079 }
11080 }
11081 MarketUiMode::ListPick { source, index } => {
11082 let opts = self.state.market_list_item_options(source);
11083 let n = opts.len();
11084 if n == 0 {
11085 return;
11086 }
11087 let next = page_list_index(*index, pages, n);
11088 self.state.market_ui_mode = MarketUiMode::ListPick {
11089 source: source.clone(),
11090 index: next,
11091 };
11092 }
11093 _ => {}
11094 }
11095 }
11096
11097 pub fn market_cycle_category(&mut self, delta: i32) {
11098 let groups = self.state.market_available_category_groups();
11099 let mut labels: Vec<Option<&'static str>> = vec![None];
11101 labels.extend(groups.into_iter().map(Some));
11102 let n = labels.len() as i32;
11103 let cur = labels
11104 .iter()
11105 .position(|g| *g == self.state.market_category_filter)
11106 .unwrap_or(0) as i32;
11107 let next = (cur + delta).rem_euclid(n) as usize;
11108 self.state.market_category_filter = labels[next];
11109 self.state.market_menu_index = 0;
11110 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
11111 let source = source.clone();
11112 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
11113 }
11114 }
11115
11116 pub fn focus_market_filter(&mut self) {
11117 self.state.market_filter_focused = true;
11118 }
11119
11120 pub fn append_market_filter_char(&mut self, ch: char) {
11121 if !self.state.market_filter_focused {
11122 return;
11123 }
11124 if !is_list_filter_char(ch) {
11125 return;
11126 }
11127 if self.state.market_filter.len() < 48 {
11128 self.state.market_filter.push(ch);
11129 self.state.market_menu_index = 0;
11130 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
11131 let source = source.clone();
11132 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
11133 }
11134 }
11135 }
11136
11137 pub fn market_filter_backspace(&mut self) {
11138 if !self.state.market_filter_focused {
11139 return;
11140 }
11141 self.state.market_filter.pop();
11142 self.state.market_menu_index = 0;
11143 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
11144 let source = source.clone();
11145 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
11146 }
11147 }
11148
11149 pub fn clear_or_blur_market_filter(&mut self) -> bool {
11151 if self.state.market_filter_focused {
11152 if !self.state.market_filter.is_empty() {
11153 self.state.market_filter.clear();
11154 self.state.market_menu_index = 0;
11155 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
11156 let source = source.clone();
11157 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
11158 }
11159 return true;
11160 }
11161 self.state.market_filter_focused = false;
11162 return true;
11163 }
11164 if !self.state.market_filter.is_empty() {
11165 self.state.market_filter.clear();
11166 self.state.market_menu_index = 0;
11167 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
11168 let source = source.clone();
11169 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
11170 }
11171 return true;
11172 }
11173 false
11174 }
11175
11176 pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
11177 if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
11178 return self.market_confirm_buy(listing_id, qty).await;
11179 }
11180 let Some(panel) = self.state.market_panel.clone() else {
11181 return Ok(());
11182 };
11183 let indices = self.state.market_filtered_listing_indices();
11184 let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
11185 return Ok(());
11186 };
11187 let Some(listing) = panel.listings.get(raw_idx) else {
11188 return Ok(());
11189 };
11190 if listing.mine {
11191 self.seq += 1;
11192 self.session
11193 .submit_intent(Intent::MarketDelist {
11194 entity_id: self.state.entity_id,
11195 npc_id: panel.npc_id.clone(),
11196 listing_id: listing.listing_id,
11197 dest: flatland_protocol::GoodsLocation::Person,
11198 seq: self.seq,
11199 })
11200 .await?;
11201 self.state.intents_sent += 1;
11202 return Ok(());
11203 }
11204 if listing.npc_price {
11205 self.state
11206 .push_log("NPC-price listings are bought by merchants only.");
11207 return Ok(());
11208 }
11209 let qty = 1u32.min(listing.quantity).max(1);
11210 let line = listing.unit_price_copper.saturating_mul(qty as u64);
11211 self.state.market_buy_confirm = Some((
11212 listing.listing_id,
11213 qty,
11214 listing.unit_price_copper,
11215 line,
11216 listing.display_name.clone(),
11217 ));
11218 Ok(())
11219 }
11220
11221 pub async fn market_confirm_buy(
11222 &mut self,
11223 listing_id: uuid::Uuid,
11224 quantity: u32,
11225 ) -> anyhow::Result<()> {
11226 let Some(panel) = self.state.market_panel.clone() else {
11227 self.state.market_buy_confirm = None;
11228 return Ok(());
11229 };
11230 self.state.market_buy_confirm = None;
11231 self.seq += 1;
11232 self.session
11233 .submit_intent(Intent::MarketBuy {
11234 entity_id: self.state.entity_id,
11235 npc_id: panel.npc_id,
11236 listing_id,
11237 quantity,
11238 dest: flatland_protocol::GoodsLocation::Person,
11239 seq: self.seq,
11240 })
11241 .await?;
11242 self.state.intents_sent += 1;
11243 Ok(())
11244 }
11245
11246 pub fn market_begin_list(&mut self) {
11248 if self.state.market_panel.is_none() {
11249 return;
11250 }
11251 let sources = self.state.market_list_source_options();
11252 if sources.is_empty() {
11253 self.state.push_log("Nothing to list from.");
11254 return;
11255 }
11256 if sources.len() == 1 {
11258 let (source, _) = sources[0].clone();
11259 let opts = self.state.market_list_item_options(&source);
11260 if opts.is_empty() {
11261 self.state.push_log("Nothing loose to list.");
11262 return;
11263 }
11264 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
11265 self.state.market_buy_confirm = None;
11266 return;
11267 }
11268 self.state.market_buy_confirm = None;
11269 self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
11270 }
11271
11272 pub fn market_ui_back(&mut self) {
11273 self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
11274 MarketUiMode::Browse => MarketUiMode::Browse,
11275 MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
11276 MarketUiMode::ListPick { .. } => {
11277 if self.state.market_list_source_options().len() <= 1 {
11278 MarketUiMode::Browse
11279 } else {
11280 MarketUiMode::ListSource { index: 0 }
11281 }
11282 }
11283 MarketUiMode::ListAmount {
11284 source, pick_index, ..
11285 } => MarketUiMode::ListPick {
11286 source,
11287 index: pick_index,
11288 },
11289 MarketUiMode::ListPricingMode {
11290 source,
11291 item_instance_id,
11292 template_id,
11293 label,
11294 max_qty,
11295 quantity,
11296 pick_index,
11297 ..
11298 } => {
11299 let input = quantity.map(|q| q.to_string()).unwrap_or_default();
11300 MarketUiMode::ListAmount {
11301 source,
11302 pick_index,
11303 item_instance_id,
11304 template_id,
11305 label,
11306 max_qty,
11307 input,
11308 }
11309 }
11310 MarketUiMode::ListPrice {
11311 source,
11312 pick_index,
11313 item_instance_id,
11314 template_id,
11315 label,
11316 max_qty,
11317 quantity,
11318 ..
11319 } => MarketUiMode::ListPricingMode {
11320 source,
11321 pick_index,
11322 item_instance_id,
11323 template_id,
11324 label,
11325 quantity,
11326 max_qty,
11327 index: 1,
11328 },
11329 };
11330 }
11331
11332 pub fn market_list_move(&mut self, delta: i32) {
11333 match &self.state.market_ui_mode {
11334 MarketUiMode::ListSource { index } => {
11335 let n = self.state.market_list_source_options().len();
11336 if n == 0 {
11337 return;
11338 }
11339 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
11340 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
11341 }
11342 MarketUiMode::ListPricingMode { index, .. } => {
11343 let next = (*index as i32 + delta).rem_euclid(2) as usize;
11344 if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
11345 {
11346 *index = next;
11347 }
11348 }
11349 MarketUiMode::ListPick { source, index } => {
11350 let opts = self.state.market_list_item_options(source);
11351 let n = opts.len();
11352 if n == 0 {
11353 return;
11354 }
11355 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
11356 self.state.market_ui_mode = MarketUiMode::ListPick {
11357 source: source.clone(),
11358 index: next,
11359 };
11360 }
11361 _ => {}
11362 }
11363 }
11364
11365 pub fn market_list_amount_append_char(&mut self, c: char) {
11366 if !c.is_ascii_digit() {
11367 return;
11368 }
11369 match &mut self.state.market_ui_mode {
11370 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
11371 if input.len() < 12 {
11372 input.push(c);
11373 }
11374 }
11375 _ => {}
11376 }
11377 }
11378
11379 pub fn market_list_amount_backspace(&mut self) {
11380 match &mut self.state.market_ui_mode {
11381 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
11382 input.pop();
11383 }
11384 _ => {}
11385 }
11386 }
11387
11388 pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
11389 match self.state.market_ui_mode.clone() {
11390 MarketUiMode::Browse => Ok(()),
11391 MarketUiMode::ListSource { index } => {
11392 let sources = self.state.market_list_source_options();
11393 let Some((source, _)) = sources.get(index).cloned() else {
11394 return Ok(());
11395 };
11396 let opts = self.state.market_list_item_options(&source);
11397 if opts.is_empty() {
11398 self.state.push_log("Nothing to list from that source.");
11399 return Ok(());
11400 }
11401 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
11402 Ok(())
11403 }
11404 MarketUiMode::ListPick { source, index } => {
11405 let opts = self.state.market_list_item_options(&source);
11406 let Some(opt) = opts.get(index) else {
11407 self.state.push_log("Nothing to list.");
11408 self.state.market_ui_mode = MarketUiMode::Browse;
11409 return Ok(());
11410 };
11411 self.state.market_ui_mode = MarketUiMode::ListAmount {
11412 source,
11413 pick_index: index,
11414 item_instance_id: opt.item_instance_id,
11415 template_id: opt.template_id.clone(),
11416 label: opt.label.clone(),
11417 max_qty: opt.quantity.max(1),
11418 input: String::new(),
11419 };
11420 Ok(())
11421 }
11422 MarketUiMode::ListAmount {
11423 source,
11424 pick_index,
11425 item_instance_id,
11426 template_id,
11427 label,
11428 max_qty,
11429 input,
11430 ..
11431 } => {
11432 let Some(qty_opt) = parse_storage_quantity(&input) else {
11433 self.state.push_log("Enter a quantity (blank = all).");
11434 return Ok(());
11435 };
11436 if let Some(q) = qty_opt {
11437 if q > max_qty {
11438 self.state.push_log(format!("Only {max_qty} available."));
11439 return Ok(());
11440 }
11441 }
11442 self.state.market_ui_mode = MarketUiMode::ListPricingMode {
11443 source,
11444 pick_index,
11445 item_instance_id,
11446 template_id,
11447 label,
11448 quantity: qty_opt,
11449 max_qty,
11450 index: 0,
11451 };
11452 Ok(())
11453 }
11454 MarketUiMode::ListPricingMode {
11455 source,
11456 pick_index,
11457 item_instance_id,
11458 template_id,
11459 label,
11460 quantity,
11461 max_qty,
11462 index,
11463 } => {
11464 if index == 0 {
11465 if self
11466 .state
11467 .npc_market_dump_unit_estimate(&template_id)
11468 .is_none()
11469 {
11470 self.state
11471 .push_log("That item has no NPC value — use a fixed price instead.");
11472 return Ok(());
11473 }
11474 return self
11475 .submit_market_list_intent(
11476 source,
11477 item_instance_id,
11478 quantity,
11479 0,
11480 true,
11481 &label,
11482 )
11483 .await;
11484 }
11485 self.state.market_ui_mode = MarketUiMode::ListPrice {
11486 source,
11487 pick_index,
11488 item_instance_id,
11489 template_id,
11490 label,
11491 quantity,
11492 max_qty,
11493 input: String::new(),
11494 };
11495 Ok(())
11496 }
11497 MarketUiMode::ListPrice {
11498 source,
11499 item_instance_id,
11500 label,
11501 quantity,
11502 input,
11503 ..
11504 } => {
11505 let price = input.trim().parse::<u64>().unwrap_or(0);
11506 if price == 0 {
11507 self.state
11508 .push_log("Enter a unit price of at least 1 copper.");
11509 return Ok(());
11510 }
11511 self.submit_market_list_intent(
11512 source,
11513 item_instance_id,
11514 quantity,
11515 price,
11516 false,
11517 &label,
11518 )
11519 .await
11520 }
11521 }
11522 }
11523
11524 async fn submit_market_list_intent(
11525 &mut self,
11526 source: MarketListSourceKind,
11527 item_instance_id: uuid::Uuid,
11528 quantity: Option<u32>,
11529 unit_price_copper: u64,
11530 npc_price: bool,
11531 label: &str,
11532 ) -> anyhow::Result<()> {
11533 let Some(panel) = self.state.market_panel.clone() else {
11534 self.state.market_ui_mode = MarketUiMode::Browse;
11535 return Ok(());
11536 };
11537 let goods = match source {
11538 MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
11539 MarketListSourceKind::TownStorage { building_id } => {
11540 flatland_protocol::GoodsLocation::TownStorage { building_id }
11541 }
11542 };
11543 self.seq += 1;
11544 self.session
11545 .submit_intent(Intent::MarketList {
11546 entity_id: self.state.entity_id,
11547 npc_id: panel.npc_id,
11548 source: goods,
11549 item_instance_id,
11550 quantity,
11551 unit_price_copper,
11552 npc_price,
11553 seq: self.seq,
11554 })
11555 .await?;
11556 self.state.intents_sent += 1;
11557 if npc_price {
11558 self.state
11559 .push_log(format!("Listing {label} at NPC price…"));
11560 } else {
11561 self.state
11562 .push_log(format!("Listing {label} @ {unit_price_copper} cp…"));
11563 }
11564 self.state.market_ui_mode = MarketUiMode::Browse;
11565 Ok(())
11566 }
11567
11568 pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
11570 let return_to_verbs = self.state.npc_verb_target.is_some();
11571 self.close_shop_menu().await?;
11572 if return_to_verbs {
11573 self.state.show_npc_verb_menu = true;
11574 self.state.npc_verb_notice = None;
11575 }
11576 Ok(())
11577 }
11578
11579 pub fn shop_tab_toggle(&mut self) {
11580 self.state.shop_tab = match self.state.shop_tab {
11581 ShopTab::Buy => ShopTab::Sell,
11582 ShopTab::Sell => ShopTab::Buy,
11583 };
11584 self.state.shop_menu_index = 0;
11585 if self.state.shop_tab == ShopTab::Sell {
11586 self.state.shop_quantity_set_max();
11587 }
11588 self.state.clamp_shop_selection();
11589 }
11590
11591 pub fn shop_menu_move(&mut self, delta: i32) {
11592 self.state.shop_menu_move(delta);
11593 }
11594
11595 pub fn shop_quantity_adjust(&mut self, delta: i32) {
11596 self.state.shop_quantity_adjust(delta);
11597 }
11598
11599 pub fn shop_quantity_set_max(&mut self) {
11600 self.state.shop_quantity_set_max();
11601 }
11602
11603 pub fn shop_quantity_set_min(&mut self) {
11604 self.state.shop_quantity_set_min();
11605 }
11606
11607 pub fn toggle_quest_menu(&mut self) {
11608 self.state.show_quest_menu = !self.state.show_quest_menu;
11609 if self.state.show_quest_menu {
11610 self.state.quest_menu_index = 0;
11611 self.state.quest_withdraw_confirm = false;
11612 self.state.show_workers_menu = false;
11613 }
11614 }
11615
11616 pub fn toggle_workers_menu(&mut self) {
11617 if self.state.show_workers_menu {
11618 self.close_workers_menu_ui();
11619 } else {
11620 self.state.show_workers_menu = true;
11621 self.state.social_chat.picking_stone = false;
11622 if self.state.workers_menu_index >= self.state.hired_workers.len() {
11624 self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
11625 }
11626 self.state.show_quest_menu = false;
11627 self.close_worker_give_picker();
11628 self.close_worker_give_target_picker();
11629 self.close_worker_take_picker();
11630 self.close_worker_teach_picker();
11631 self.cancel_worker_rename();
11632 }
11633 }
11634
11635 pub fn close_workers_menu_ui(&mut self) {
11637 self.state.show_workers_menu = false;
11638 self.cancel_worker_dismissal();
11639 self.close_worker_give_picker();
11640 self.close_worker_give_target_picker();
11641 self.close_worker_take_picker();
11642 self.close_worker_teach_picker();
11643 self.cancel_worker_rename();
11644 }
11645
11646 pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
11648 let Some(idx) = self
11649 .state
11650 .hired_workers
11651 .iter()
11652 .position(|w| w.instance_id == instance_id)
11653 else {
11654 anyhow::bail!("worker not found");
11655 };
11656 let label = self.state.hired_workers[idx].label.clone();
11657 self.state.show_workers_menu = true;
11658 self.state.social_chat.picking_stone = false;
11659 self.state.workers_menu_index = idx;
11660 self.state.show_quest_menu = false;
11661 self.close_worker_give_picker();
11662 self.close_worker_give_target_picker();
11663 self.close_worker_take_picker();
11664 self.close_worker_teach_picker();
11665 self.cancel_worker_rename();
11666 self.set_worker_attending(instance_id, true).await?;
11667 self.state
11668 .push_log(format!("Managing {label} — job paused while menu is open"));
11669 Ok(())
11670 }
11671
11672 pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
11674 self.close_workers_menu_ui();
11675 self.release_worker_attend().await
11676 }
11677
11678 async fn set_worker_attending(
11679 &mut self,
11680 instance_id: &str,
11681 attending: bool,
11682 ) -> anyhow::Result<()> {
11683 if attending {
11684 if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
11685 return Ok(());
11686 }
11687 if let Some(prev) = self.state.attending_worker_instance_id.clone() {
11689 if prev != instance_id {
11690 self.send_attend_hired_worker(&prev, false).await?;
11691 }
11692 }
11693 self.send_attend_hired_worker(instance_id, true).await?;
11694 self.state.attending_worker_instance_id = Some(instance_id.to_string());
11695 } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
11696 self.send_attend_hired_worker(instance_id, false).await?;
11697 self.state.attending_worker_instance_id = None;
11698 }
11699 Ok(())
11700 }
11701
11702 pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
11703 let Some(id) = self.state.attending_worker_instance_id.take() else {
11704 return Ok(());
11705 };
11706 self.send_attend_hired_worker(&id, false).await
11707 }
11708
11709 async fn send_attend_hired_worker(
11710 &mut self,
11711 worker_instance_id: &str,
11712 attending: bool,
11713 ) -> anyhow::Result<()> {
11714 self.seq += 1;
11715 self.session
11716 .submit_intent(Intent::AttendHiredWorker {
11717 entity_id: self.state.entity_id,
11718 worker_instance_id: worker_instance_id.to_string(),
11719 attending,
11720 seq: self.seq,
11721 })
11722 .await?;
11723 self.state.intents_sent += 1;
11724 Ok(())
11725 }
11726
11727 pub fn workers_menu_move(&mut self, delta: i32) {
11728 let n = self.state.hired_workers.len();
11729 if n == 0 {
11730 return;
11731 }
11732 let idx = self.state.workers_menu_index as i32;
11733 self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
11734 }
11735
11736 pub fn toggle_workers_menu_compact(&mut self) {
11737 self.state.workers_menu_compact = !self.state.workers_menu_compact;
11738 let mut cfg = crate::client_config::ClientConfig::load();
11739 let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
11740 }
11741
11742 pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
11743 let Some(worker) = self
11744 .state
11745 .hired_workers
11746 .get(self.state.workers_menu_index)
11747 .cloned()
11748 else {
11749 anyhow::bail!("no worker selected");
11750 };
11751 self.dismiss_worker_by_id(&worker.instance_id, &worker.label)
11752 .await
11753 }
11754
11755 pub fn request_worker_dismissal(&mut self) -> anyhow::Result<()> {
11757 let Some(worker) = self
11758 .state
11759 .hired_workers
11760 .get(self.state.workers_menu_index)
11761 .cloned()
11762 else {
11763 anyhow::bail!("no worker selected");
11764 };
11765 self.state.worker_dismiss_confirmation = Some(WorkerDismissConfirmation {
11766 worker_instance_id: worker.instance_id,
11767 worker_label: worker.label,
11768 });
11769 Ok(())
11770 }
11771
11772 pub fn cancel_worker_dismissal(&mut self) {
11773 self.state.worker_dismiss_confirmation = None;
11774 }
11775
11776 pub async fn confirm_worker_dismissal(&mut self) -> anyhow::Result<()> {
11777 let Some(confirm) = self.state.worker_dismiss_confirmation.clone() else {
11778 return Ok(());
11779 };
11780 self.dismiss_worker_by_id(&confirm.worker_instance_id, &confirm.worker_label)
11781 .await?;
11782 self.cancel_worker_dismissal();
11783 Ok(())
11784 }
11785
11786 async fn dismiss_worker_by_id(
11787 &mut self,
11788 worker_instance_id: &str,
11789 worker_label: &str,
11790 ) -> anyhow::Result<()> {
11791 self.seq += 1;
11792 self.session
11793 .submit_intent(Intent::DismissWorker {
11794 entity_id: self.state.entity_id,
11795 worker_instance_id: worker_instance_id.to_string(),
11796 seq: self.seq,
11797 })
11798 .await?;
11799 self.state.intents_sent += 1;
11800 self.state
11801 .hired_workers
11802 .retain(|w| w.instance_id != worker_instance_id);
11803 if self.state.workers_menu_index >= self.state.hired_workers.len() {
11804 self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
11805 }
11806 self.state.push_log(format!("Dismissed {worker_label}"));
11807 Ok(())
11808 }
11809
11810 pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
11811 let Some(worker) = self
11812 .state
11813 .hired_workers
11814 .get(self.state.workers_menu_index)
11815 .cloned()
11816 else {
11817 anyhow::bail!("no worker selected");
11818 };
11819 let mode = match worker.mode {
11820 flatland_protocol::WorkerModeView::Companion => "defender",
11821 flatland_protocol::WorkerModeView::Defender => "job_loop",
11822 flatland_protocol::WorkerModeView::JobLoop => "idle",
11823 flatland_protocol::WorkerModeView::Idle => "companion",
11824 };
11825 self.seq += 1;
11826 self.session
11827 .submit_intent(Intent::SetWorkerMode {
11828 entity_id: self.state.entity_id,
11829 worker_instance_id: worker.instance_id,
11830 mode: mode.into(),
11831 seq: self.seq,
11832 })
11833 .await?;
11834 self.state.intents_sent += 1;
11835 Ok(())
11836 }
11837
11838 pub async fn workers_deliver_selected_to_storage(&mut self) -> anyhow::Result<()> {
11839 let Some(worker) = self
11840 .state
11841 .hired_workers
11842 .get(self.state.workers_menu_index)
11843 .cloned()
11844 else {
11845 anyhow::bail!("no worker selected");
11846 };
11847 if !matches!(worker.mode, flatland_protocol::WorkerModeView::Companion) {
11848 anyhow::bail!("switch the worker to companion mode first");
11849 }
11850 if worker.step_label.starts_with("delivering to ")
11851 || worker.step_label == "returning to you"
11852 {
11853 anyhow::bail!("worker is already delivering to storage");
11854 }
11855 self.seq += 1;
11856 self.session
11857 .submit_intent(Intent::DeliverWorkerToNearestStorage {
11858 entity_id: self.state.entity_id,
11859 worker_instance_id: worker.instance_id.clone(),
11860 seq: self.seq,
11861 })
11862 .await?;
11863 self.state.intents_sent += 1;
11864 self.state.push_log(format!(
11865 "{} is delivering carried items to storage",
11866 worker.label
11867 ));
11868 Ok(())
11869 }
11870
11871 pub async fn workers_cancel_delivery_selected(&mut self) -> anyhow::Result<()> {
11872 let Some(worker) = self
11873 .state
11874 .hired_workers
11875 .get(self.state.workers_menu_index)
11876 .cloned()
11877 else {
11878 anyhow::bail!("no worker selected");
11879 };
11880 if !(worker.step_label.starts_with("delivering to ")
11881 || worker.step_label == "returning to you")
11882 {
11883 anyhow::bail!("worker has no active delivery");
11884 }
11885 self.seq += 1;
11886 self.session
11887 .submit_intent(Intent::CancelWorkerDelivery {
11888 entity_id: self.state.entity_id,
11889 worker_instance_id: worker.instance_id,
11890 seq: self.seq,
11891 })
11892 .await?;
11893 self.state.intents_sent += 1;
11894 self.state
11895 .push_log(format!("Canceled delivery for {}", worker.label));
11896 Ok(())
11897 }
11898
11899 pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
11900 if self.state.hired_workers.is_empty() {
11901 return self.hire_worker_laborer().await;
11902 }
11903 self.workers_toggle_mode_selected().await
11904 }
11905
11906 pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
11909 let row = self
11910 .state
11911 .inventory_selected_row()
11912 .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
11913 .clone();
11914 if row.from != flatland_protocol::InventoryLocation::Root {
11915 anyhow::bail!("select a carried item to give");
11916 }
11917 let Some(instance_id) = row.stack.item_instance_id else {
11918 anyhow::bail!("that stack can't be given");
11919 };
11920 let options = self.nearby_worker_give_targets();
11921 if options.is_empty() {
11922 anyhow::bail!(
11923 "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
11924 );
11925 }
11926 let item_label = row
11927 .stack
11928 .display_name
11929 .as_deref()
11930 .unwrap_or(&row.stack.template_id)
11931 .to_string();
11932 self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
11933 item_instance_id: instance_id,
11934 item_label,
11935 quantity: None,
11936 options,
11937 });
11938 self.state.worker_give_target_picker_index = 0;
11939 self.state.show_worker_give_target_picker = true;
11940 self.state.show_inventory_menu = false;
11942 Ok(())
11943 }
11944
11945 pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
11947 let (px, py, _) = self.state.player_position_with_z();
11948 let mut options: Vec<WorkerGiveTargetOption> = self
11949 .state
11950 .hired_workers
11951 .iter()
11952 .filter_map(|w| {
11953 let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
11954 if dist > WORKER_GIVE_RANGE_M {
11955 return None;
11956 }
11957 Some(WorkerGiveTargetOption {
11958 instance_id: w.instance_id.clone(),
11959 label: w.label.clone(),
11960 distance_m: dist,
11961 })
11962 })
11963 .collect();
11964 options.sort_by(|a, b| {
11965 a.distance_m
11966 .partial_cmp(&b.distance_m)
11967 .unwrap_or(std::cmp::Ordering::Equal)
11968 });
11969 options
11970 }
11971
11972 pub fn close_worker_give_target_picker(&mut self) {
11973 self.state.show_worker_give_target_picker = false;
11974 self.state.worker_give_target_picker = None;
11975 self.state.worker_give_target_picker_index = 0;
11976 }
11977
11978 pub fn worker_give_target_picker_move(&mut self, delta: i32) {
11979 let Some(picker) = &self.state.worker_give_target_picker else {
11980 return;
11981 };
11982 let n = picker.options.len();
11983 if n == 0 {
11984 return;
11985 }
11986 let idx = self.state.worker_give_target_picker_index as i32;
11987 self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11988 }
11989
11990 pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
11991 let Some(picker) = self.state.worker_give_target_picker.clone() else {
11992 anyhow::bail!("give target picker not open");
11993 };
11994 let Some(opt) = picker
11995 .options
11996 .get(self.state.worker_give_target_picker_index)
11997 .cloned()
11998 else {
11999 anyhow::bail!("no worker selected");
12000 };
12001 let Some(worker) = self
12002 .state
12003 .hired_workers
12004 .iter()
12005 .find(|w| w.instance_id == opt.instance_id)
12006 .cloned()
12007 else {
12008 self.close_worker_give_target_picker();
12009 anyhow::bail!("worker no longer hired");
12010 };
12011 self.give_item_to_worker(
12012 &worker.instance_id,
12013 &worker.label,
12014 worker.x,
12015 worker.y,
12016 picker.item_instance_id,
12017 &picker.item_label,
12018 picker.quantity,
12019 )
12020 .await?;
12021 self.close_worker_give_target_picker();
12022 Ok(())
12023 }
12024
12025 pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
12027 self.open_worker_give_target_picker()
12028 }
12029
12030 pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
12032 let Some(worker) = self
12033 .state
12034 .hired_workers
12035 .get(self.state.workers_menu_index)
12036 .cloned()
12037 else {
12038 anyhow::bail!("select a hired worker first");
12039 };
12040 let (px, py, _) = self.state.player_position_with_z();
12041 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
12042 if dist > WORKER_GIVE_RANGE_M {
12043 anyhow::bail!(
12044 "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
12045 worker.label
12046 );
12047 }
12048 let options = self.state.giveable_inventory_options();
12049 if options.is_empty() {
12050 anyhow::bail!("nothing in inventory to give");
12051 }
12052 self.state.worker_give_picker = Some(WorkerGivePicker {
12053 worker_instance_id: worker.instance_id,
12054 worker_label: worker.label,
12055 options,
12056 });
12057 self.state.worker_give_picker_index = 0;
12058 self.state.show_worker_give_picker = true;
12059 Ok(())
12060 }
12061
12062 pub fn close_worker_give_picker(&mut self) {
12063 self.state.show_worker_give_picker = false;
12064 self.state.worker_give_picker = None;
12065 self.state.worker_give_picker_index = 0;
12066 }
12067
12068 pub fn worker_give_picker_move(&mut self, delta: i32) {
12069 let Some(picker) = &self.state.worker_give_picker else {
12070 return;
12071 };
12072 let n = picker.options.len();
12073 if n == 0 {
12074 return;
12075 }
12076 let idx = self.state.worker_give_picker_index as i32;
12077 self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
12078 }
12079
12080 pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
12082 let Some(picker) = self.state.worker_give_picker.clone() else {
12083 anyhow::bail!("give picker not open");
12084 };
12085 let Some(opt) = picker
12086 .options
12087 .get(self.state.worker_give_picker_index)
12088 .cloned()
12089 else {
12090 anyhow::bail!("no item selected");
12091 };
12092 let Some(worker) = self
12093 .state
12094 .hired_workers
12095 .iter()
12096 .find(|w| w.instance_id == picker.worker_instance_id)
12097 .cloned()
12098 else {
12099 self.close_worker_give_picker();
12100 anyhow::bail!("worker no longer hired");
12101 };
12102 self.give_item_to_worker(
12103 &worker.instance_id,
12104 &worker.label,
12105 worker.x,
12106 worker.y,
12107 opt.item_instance_id,
12108 &opt.label,
12109 None,
12110 )
12111 .await?;
12112 let options = self.state.giveable_inventory_options();
12114 if options.is_empty() {
12115 self.close_worker_give_picker();
12116 } else {
12117 self.state.worker_give_picker = Some(WorkerGivePicker {
12118 worker_instance_id: picker.worker_instance_id,
12119 worker_label: picker.worker_label,
12120 options,
12121 });
12122 if self.state.worker_give_picker_index
12123 >= self
12124 .state
12125 .worker_give_picker
12126 .as_ref()
12127 .map(|p| p.options.len())
12128 .unwrap_or(0)
12129 {
12130 self.state.worker_give_picker_index = self
12131 .state
12132 .worker_give_picker
12133 .as_ref()
12134 .map(|p| p.options.len().saturating_sub(1))
12135 .unwrap_or(0);
12136 }
12137 }
12138 Ok(())
12139 }
12140
12141 pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
12143 let Some(worker) = self
12144 .state
12145 .hired_workers
12146 .get(self.state.workers_menu_index)
12147 .cloned()
12148 else {
12149 anyhow::bail!("select a hired worker first");
12150 };
12151 let (px, py, _) = self.state.player_position_with_z();
12152 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
12153 if dist > WORKER_GIVE_RANGE_M {
12154 anyhow::bail!(
12155 "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
12156 worker.label
12157 );
12158 }
12159 let options = self.state.teachable_blueprint_options(&worker);
12160 if options.is_empty() {
12161 anyhow::bail!("no recipes you know that {} still needs", worker.label);
12162 }
12163 self.state.worker_teach_picker = Some(WorkerTeachPicker {
12164 worker_instance_id: worker.instance_id,
12165 worker_label: worker.label,
12166 worker_level: worker.level,
12167 options,
12168 });
12169 self.state.worker_teach_picker_index = 0;
12170 self.state.show_worker_teach_picker = true;
12171 Ok(())
12172 }
12173
12174 pub fn close_worker_teach_picker(&mut self) {
12175 self.state.show_worker_teach_picker = false;
12176 self.state.worker_teach_picker = None;
12177 self.state.worker_teach_picker_index = 0;
12178 }
12179
12180 pub fn worker_teach_picker_move(&mut self, delta: i32) {
12181 let Some(picker) = &self.state.worker_teach_picker else {
12182 return;
12183 };
12184 let n = picker.options.len();
12185 if n == 0 {
12186 return;
12187 }
12188 let idx = self.state.worker_teach_picker_index as i32;
12189 self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
12190 }
12191
12192 pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
12193 let Some(picker) = self.state.worker_teach_picker.clone() else {
12194 anyhow::bail!("teach picker not open");
12195 };
12196 let Some(opt) = picker
12197 .options
12198 .get(self.state.worker_teach_picker_index)
12199 .cloned()
12200 else {
12201 anyhow::bail!("nothing selected");
12202 };
12203 if !opt.level_ok {
12204 anyhow::bail!(
12205 "{} needs level {} (is level {})",
12206 picker.worker_label,
12207 opt.min_level,
12208 opt.worker_level
12209 );
12210 }
12211 if !opt.can_afford {
12212 anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
12213 }
12214 let Some(worker) = self
12215 .state
12216 .hired_workers
12217 .iter()
12218 .find(|w| w.instance_id == picker.worker_instance_id)
12219 .cloned()
12220 else {
12221 anyhow::bail!("worker gone");
12222 };
12223 let (px, py, _) = self.state.player_position_with_z();
12224 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
12225 if dist > WORKER_GIVE_RANGE_M {
12226 anyhow::bail!("worker {} too far — stand next to them", worker.label);
12227 }
12228 self.seq += 1;
12229 self.session
12230 .submit_intent(Intent::TeachWorkerBlueprint {
12231 entity_id: self.state.entity_id,
12232 worker_instance_id: picker.worker_instance_id.clone(),
12233 blueprint_id: opt.blueprint_id.clone(),
12234 seq: self.seq,
12235 })
12236 .await?;
12237 self.state.intents_sent += 1;
12238 self.state.push_log(format!(
12239 "Teaching {} to {} ({} cp)",
12240 opt.label, picker.worker_label, opt.cost_copper
12241 ));
12242 self.close_worker_teach_picker();
12243 Ok(())
12244 }
12245
12246 async fn give_item_to_worker(
12247 &mut self,
12248 worker_instance_id: &str,
12249 worker_label: &str,
12250 worker_x: f32,
12251 worker_y: f32,
12252 item_instance_id: uuid::Uuid,
12253 item_label: &str,
12254 quantity: Option<u32>,
12255 ) -> anyhow::Result<()> {
12256 let (px, py, _) = self.state.player_position_with_z();
12257 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
12258 if dist > WORKER_GIVE_RANGE_M {
12259 anyhow::bail!("worker {worker_label} too far — stand next to them");
12260 }
12261 self.seq += 1;
12262 self.session
12263 .submit_intent(Intent::GiveWorkerItem {
12264 entity_id: self.state.entity_id,
12265 worker_instance_id: worker_instance_id.to_string(),
12266 item_instance_id,
12267 quantity,
12268 seq: self.seq,
12269 })
12270 .await?;
12271 self.state.intents_sent += 1;
12272 self.state
12273 .remove_carried_instance(item_instance_id, quantity);
12274 self.state
12275 .push_log(format!("Gave {item_label} to {worker_label}"));
12276 Ok(())
12277 }
12278
12279 pub async fn equip_item_on_worker(
12283 &mut self,
12284 worker_instance_id: &str,
12285 item_instance_id: uuid::Uuid,
12286 slot: &str,
12287 ) -> anyhow::Result<()> {
12288 let Some(worker) = self
12289 .state
12290 .hired_workers
12291 .iter()
12292 .find(|worker| worker.instance_id == worker_instance_id)
12293 .cloned()
12294 else {
12295 anyhow::bail!("worker not found");
12296 };
12297 let (px, py, _) = self.state.player_position_with_z();
12298 if (worker.x - px).hypot(worker.y - py) > WORKER_GIVE_RANGE_M {
12299 anyhow::bail!("worker {} too far — stand next to them", worker.label);
12300 }
12301 self.seq += 1;
12302 self.session
12303 .submit_intent(Intent::EquipWorkerItem {
12304 entity_id: self.state.entity_id,
12305 worker_instance_id: worker.instance_id.clone(),
12306 item_instance_id,
12307 slot: slot.to_string(),
12308 seq: self.seq,
12309 })
12310 .await?;
12311 self.state.intents_sent += 1;
12312 self.state
12313 .push_log(format!("Equipped {slot} on {}", worker.label));
12314 Ok(())
12315 }
12316
12317 pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
12319 let Some(worker) = self
12320 .state
12321 .hired_workers
12322 .get(self.state.workers_menu_index)
12323 .cloned()
12324 else {
12325 anyhow::bail!("select a hired worker first");
12326 };
12327 let (px, py, _) = self.state.player_position_with_z();
12328 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
12329 if dist > WORKER_GIVE_RANGE_M {
12330 anyhow::bail!(
12331 "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
12332 worker.label
12333 );
12334 }
12335 let options = Self::worker_inventory_options(&worker);
12336 if options.is_empty() {
12337 anyhow::bail!("{} isn't carrying anything", worker.label);
12338 }
12339 let initial_qty = options
12340 .first()
12341 .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
12342 .unwrap_or(1);
12343 self.state.worker_take_picker = Some(WorkerTakePicker {
12344 worker_instance_id: worker.instance_id,
12345 worker_label: worker.label,
12346 options,
12347 quantity: initial_qty,
12348 });
12349 self.state.worker_take_picker_index = 0;
12350 self.state.show_worker_take_picker = true;
12351 Ok(())
12352 }
12353
12354 fn worker_inventory_options(
12355 worker: &flatland_protocol::HiredWorkerView,
12356 ) -> Vec<WorkerGiveOption> {
12357 worker
12358 .inventory
12359 .iter()
12360 .filter_map(|stack| {
12361 let item_instance_id = stack.item_instance_id?;
12362 let label = stack
12363 .display_name
12364 .clone()
12365 .unwrap_or_else(|| stack.template_id.clone());
12366 let label = if stack.quantity > 1 {
12367 format!("{label} ×{}", stack.quantity)
12368 } else {
12369 label
12370 };
12371 Some(WorkerGiveOption {
12372 item_instance_id,
12373 label,
12374 quantity: stack.quantity,
12375 template_id: stack.template_id.clone(),
12376 })
12377 })
12378 .collect()
12379 }
12380
12381 pub fn close_worker_take_picker(&mut self) {
12382 self.state.show_worker_take_picker = false;
12383 self.state.worker_take_picker = None;
12384 self.state.worker_take_picker_index = 0;
12385 }
12386
12387 pub fn worker_take_picker_move(&mut self, delta: i32) {
12388 let Some(picker) = &self.state.worker_take_picker else {
12389 return;
12390 };
12391 let n = picker.options.len();
12392 if n == 0 {
12393 return;
12394 }
12395 let idx = self.state.worker_take_picker_index as i32;
12396 self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
12397 self.clamp_worker_take_quantity();
12398 }
12399
12400 pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
12401 let Some(picker) = &mut self.state.worker_take_picker else {
12402 return;
12403 };
12404 let max = picker
12405 .options
12406 .get(self.state.worker_take_picker_index)
12407 .map(|o| o.quantity.max(1))
12408 .unwrap_or(1);
12409 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
12410 picker.quantity = next as u32;
12411 }
12412
12413 pub fn worker_take_picker_set_quantity_max(&mut self) {
12414 let Some(picker) = &mut self.state.worker_take_picker else {
12415 return;
12416 };
12417 let max = picker
12418 .options
12419 .get(self.state.worker_take_picker_index)
12420 .map(|o| o.quantity.max(1))
12421 .unwrap_or(1);
12422 picker.quantity = max;
12423 }
12424
12425 pub fn worker_take_picker_set_quantity_min(&mut self) {
12426 let Some(picker) = &mut self.state.worker_take_picker else {
12427 return;
12428 };
12429 picker.quantity = 1;
12430 self.clamp_worker_take_quantity();
12431 }
12432
12433 fn clamp_worker_take_quantity(&mut self) {
12434 let Some(picker) = &mut self.state.worker_take_picker else {
12435 return;
12436 };
12437 let max = picker
12438 .options
12439 .get(self.state.worker_take_picker_index)
12440 .map(|o| o.quantity.max(1))
12441 .unwrap_or(1);
12442 if picker.quantity == 0 || picker.quantity > max {
12443 picker.quantity = if max > 1 { 1 } else { max };
12444 }
12445 }
12446
12447 pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
12448 let Some(picker) = self.state.worker_take_picker.clone() else {
12449 anyhow::bail!("take picker not open");
12450 };
12451 let Some(opt) = picker
12452 .options
12453 .get(self.state.worker_take_picker_index)
12454 .cloned()
12455 else {
12456 anyhow::bail!("no item selected");
12457 };
12458 let Some(worker) = self
12459 .state
12460 .hired_workers
12461 .iter()
12462 .find(|w| w.instance_id == picker.worker_instance_id)
12463 .cloned()
12464 else {
12465 self.close_worker_take_picker();
12466 anyhow::bail!("worker no longer hired");
12467 };
12468 let qty = picker.quantity.clamp(1, opt.quantity.max(1));
12469 let intent_qty = if qty >= opt.quantity { None } else { Some(qty) };
12470 self.take_item_from_worker(
12471 &worker.instance_id,
12472 &worker.label,
12473 worker.x,
12474 worker.y,
12475 opt.item_instance_id,
12476 &opt.label,
12477 intent_qty,
12478 )
12479 .await?;
12480 Ok(())
12483 }
12484
12485 async fn take_item_from_worker(
12486 &mut self,
12487 worker_instance_id: &str,
12488 worker_label: &str,
12489 worker_x: f32,
12490 worker_y: f32,
12491 item_instance_id: uuid::Uuid,
12492 item_label: &str,
12493 quantity: Option<u32>,
12494 ) -> anyhow::Result<()> {
12495 let (px, py, _) = self.state.player_position_with_z();
12496 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
12497 if dist > WORKER_GIVE_RANGE_M {
12498 anyhow::bail!("worker {worker_label} too far — stand next to them");
12499 }
12500 self.seq += 1;
12501 self.session
12502 .submit_intent(Intent::TakeWorkerItem {
12503 entity_id: self.state.entity_id,
12504 worker_instance_id: worker_instance_id.to_string(),
12505 item_instance_id,
12506 quantity,
12507 seq: self.seq,
12508 })
12509 .await?;
12510 self.state.intents_sent += 1;
12511 let qty_note = quantity.map(|q| format!(" ×{q}")).unwrap_or_default();
12512 self.state.push_log(format!(
12513 "Taking {item_label}{qty_note} from {worker_label}…"
12514 ));
12515 Ok(())
12516 }
12517
12518 pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
12519 if let Some(since) = self.state.pending_worker_hire_since {
12520 if since.elapsed() < WORKER_HIRE_PENDING_TIMEOUT {
12521 anyhow::bail!("hire request still pending — wait for the worker roster update");
12522 }
12523 self.state.pending_worker_hire_since = None;
12524 }
12525 if !self.state.has_worker_lodging() {
12526 anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
12527 }
12528 self.seq += 1;
12529 self.session
12530 .submit_intent(Intent::HireWorker {
12531 entity_id: self.state.entity_id,
12532 def_id: "worker_laborer".into(),
12533 wage_copper_per_interval: 8,
12534 lodging_container_id: None,
12535 job_yaml: None,
12536 seq: self.seq,
12537 })
12538 .await?;
12539 self.state.intents_sent += 1;
12540 self.state.pending_worker_hire_since = Some(Instant::now());
12541 Ok(())
12542 }
12543
12544 pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
12545 let Some(worker) = self
12546 .state
12547 .hired_workers
12548 .get(self.state.workers_menu_index)
12549 .cloned()
12550 else {
12551 anyhow::bail!("select a hired worker first");
12552 };
12553 let lodging = worker.lodging_container_id.clone().or_else(|| {
12554 crate::worker_route_editor::owned_lodging_container_ids(
12555 &self.state.placed_containers,
12556 self.state.character_id,
12557 )
12558 .into_iter()
12559 .next()
12560 .map(|(id, _)| id)
12561 });
12562 let label = worker.label.clone();
12563 let editor = if let Some(route) = &worker.route {
12564 crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
12565 worker.instance_id,
12566 worker.label,
12567 route,
12568 lodging,
12569 )
12570 } else {
12571 crate::worker_route_editor::WorkerRouteEditorState::new(
12572 worker.instance_id,
12573 worker.label,
12574 lodging,
12575 )
12576 };
12577 self.state.worker_route_editor = Some(editor);
12578 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12579 if let Some(collapsed) =
12580 crate::client_config::ClientConfig::load().worker_route_panel_collapsed
12581 {
12582 ed.panel_collapsed = collapsed;
12583 }
12584 }
12585 self.state.show_workers_menu = false;
12586 self.state.push_log(format!(
12587 "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
12588 ));
12589 Ok(())
12590 }
12591
12592 pub fn close_worker_route_editor(&mut self) {
12593 self.state.worker_route_editor = None;
12594 }
12595
12596 pub fn worker_route_editor_toggle_panel(&mut self) {
12597 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12598 ed.toggle_panel_collapsed();
12599 let collapsed = ed.panel_collapsed;
12600 let mut cfg = crate::client_config::ClientConfig::load();
12601 let _ = cfg.save_worker_route_panel_collapsed(collapsed);
12602 }
12603 }
12604
12605 pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
12606 let n = {
12607 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12608 return;
12609 };
12610 ed.append_waypoint(x, y, z);
12611 ed.stop_count()
12612 };
12613 self.state
12614 .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
12615 }
12616
12617 fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
12620 let (px, py, _) = self.state.player_position_with_z();
12621 let inside = self.state.effective_inside_building();
12622 crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
12623 &self.state.placed_containers,
12624 &self.state.buildings,
12625 self.state.character_id,
12626 px,
12627 py,
12628 &self.state.hired_workers,
12629 inside.as_deref(),
12630 )
12631 }
12632
12633 fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
12634 self.state.route_editor_node_candidates()
12635 }
12636
12637 fn re_open_harvest_picker(&mut self, index: usize, picked: std::collections::BTreeSet<String>) {
12638 use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
12639 let nodes = self.state.route_editor_node_candidates();
12640 let index = if nodes.is_empty() {
12641 ROUTE_PICKER_DONE_ROW
12642 } else {
12643 index.max(1).min(nodes.len())
12644 };
12645 self.re_open_sheet(S::HarvestPicker {
12646 index,
12647 picked,
12648 nodes,
12649 });
12650 }
12651
12652 fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
12653 let (px, py, _) = self.state.player_position_with_z();
12654 let templates = self.re_template_candidates();
12655 crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py, &templates)
12656 }
12657
12658 fn re_template_candidates(&self) -> Vec<String> {
12659 let mut extra = Vec::new();
12660 if let Some(ed) = self.state.worker_route_editor.as_ref() {
12661 for stop in &ed.stops {
12662 match stop {
12663 crate::worker_route_editor::WorkerRouteStop::DepositAt {
12664 filter: Some(filter),
12665 ..
12666 } => extra.extend(filter.iter().cloned()),
12667 crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. }
12668 | crate::worker_route_editor::WorkerRouteStop::ListOnMarket {
12669 template, ..
12670 } => {
12671 extra.push(template.clone());
12672 }
12673 crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
12674 if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint)
12675 {
12676 extra.push(bp.output.clone());
12677 for input in &bp.inputs {
12678 extra.push(input.template_id.clone());
12679 }
12680 }
12681 }
12682 crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
12683 for it in items {
12684 extra.push(it.template.clone());
12685 }
12686 }
12687 _ => {}
12688 }
12689 }
12690 if let Some(worker) = self
12692 .state
12693 .hired_workers
12694 .iter()
12695 .find(|w| w.instance_id == ed.worker_instance_id)
12696 {
12697 for recipe in &worker.known_blueprint_ids {
12698 if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
12699 extra.push(bp.output.clone());
12700 }
12701 }
12702 for stack in &worker.inventory {
12703 if !stack.template_id.is_empty() && stack.quantity > 0 {
12704 extra.push(stack.template_id.clone());
12705 }
12706 }
12707 }
12708 }
12709 crate::worker_route_editor::route_item_template_candidates(
12710 &self.state.placed_containers,
12711 self.state.character_id,
12712 &self.state.inventory,
12713 &self.state.blueprints,
12714 if self.state.harvest_route_nodes.is_empty() {
12715 &self.state.resource_nodes
12716 } else {
12717 &self.state.harvest_route_nodes
12718 },
12719 &extra,
12720 Some(&self.state.item_catalog),
12721 )
12722 }
12723
12724 fn re_blueprint_ids(&self) -> Vec<String> {
12725 let worker_known: Option<&[String]> = self
12726 .state
12727 .worker_route_editor
12728 .as_ref()
12729 .and_then(|ed| {
12730 self.state
12731 .hired_workers
12732 .iter()
12733 .find(|w| w.instance_id == ed.worker_instance_id)
12734 })
12735 .map(|w| w.known_blueprint_ids.as_slice());
12736 crate::worker_route_editor::worker_craft_blueprint_ids(&self.state.blueprints, worker_known)
12737 }
12738
12739 fn re_bed_candidates(&self) -> Vec<(String, String)> {
12740 crate::worker_route_editor::owned_lodging_container_ids(
12741 &self.state.placed_containers,
12742 self.state.character_id,
12743 )
12744 }
12745
12746 fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
12747 self.state
12748 .placed_containers
12749 .iter()
12750 .find(|c| c.id == container_id)
12751 .map(|c| c.contents.clone())
12752 .unwrap_or_default()
12753 }
12754
12755 fn re_sheet_supports_filter(&self) -> bool {
12758 use crate::worker_route_editor::RouteEditorSheet as S;
12759 self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12760 matches!(
12761 ed.sheet,
12762 S::HarvestPicker { .. }
12763 | S::SellItem { .. }
12764 | S::MarketListItem { .. }
12765 | S::DepositFilter { .. }
12766 | S::WithdrawItems { .. }
12767 | S::WithdrawContainers { .. }
12768 | S::DepositContainers { .. }
12769 | S::SellNpcs { .. }
12770 | S::CraftBlueprint { .. }
12771 | S::BedPicker { .. }
12772 )
12773 })
12774 }
12775
12776 pub fn re_sheet_row_visible(&self, row: usize) -> bool {
12778 use crate::worker_route_editor::{
12779 harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
12780 ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
12781 };
12782 let Some(ed) = self.state.worker_route_editor.as_ref() else {
12783 return false;
12784 };
12785 let filter = &ed.sheet_filter;
12786 match &ed.sheet {
12787 S::HarvestPicker { nodes, .. } => harvest_picker_row_matches(nodes, row, filter),
12788 S::SellItem { templates, .. } | S::MarketListItem { templates, .. } => {
12789 if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
12790 return true;
12791 }
12792 let slot = row.saturating_sub(2);
12793 templates.get(slot).is_some_and(|t| {
12794 let label = self.state.template_display_name(t);
12795 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
12796 })
12797 }
12798 S::DepositFilter { rows, .. } => {
12799 if row >= rows.len() {
12800 return true;
12801 }
12802 rows.get(row).is_some_and(|(t, _)| {
12803 let label = self.state.template_display_name(t);
12804 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
12805 })
12806 }
12807 S::WithdrawItems { lines, .. } => {
12808 if row >= lines.len() {
12809 return true;
12810 }
12811 lines.get(row).is_some_and(|l| {
12812 let label = self.state.template_display_name(&l.template);
12813 list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
12814 })
12815 }
12816 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
12817 self.re_container_candidates().get(row).is_some_and(|c| {
12818 list_filter_row_matches(
12819 filter,
12820 Some(c.dist),
12821 &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
12822 )
12823 })
12824 }
12825 S::SellNpcs { .. } => {
12826 if row == 0 {
12827 return true;
12828 }
12829 self.re_npc_candidates().get(row - 1).is_some_and(|n| {
12830 list_filter_row_matches(
12831 filter,
12832 Some(n.dist),
12833 &[n.label.as_str(), n.id.as_str()],
12834 )
12835 })
12836 }
12837 S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
12838 let label = self
12839 .state
12840 .blueprints
12841 .iter()
12842 .find(|b| &b.id == id)
12843 .map(|b| {
12844 if b.label.is_empty() {
12845 id.as_str()
12846 } else {
12847 b.label.as_str()
12848 }
12849 })
12850 .unwrap_or(id.as_str());
12851 list_filter_row_matches(filter, None, &[id.as_str(), label])
12852 }),
12853 S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
12854 list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
12855 }),
12856 _ => true,
12857 }
12858 }
12859
12860 fn re_sheet_clamp_index(&mut self) {
12861 let count = self.re_sheet_row_count();
12862 if count == 0 {
12863 return;
12864 }
12865 let cur = self.re_sheet_index();
12866 if self.re_sheet_row_visible(cur) {
12867 return;
12868 }
12869 for offset in 1..count {
12870 if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
12871 self.re_sheet_set_index(cur + offset);
12872 return;
12873 }
12874 if cur >= offset && self.re_sheet_row_visible(cur - offset) {
12875 self.re_sheet_set_index(cur - offset);
12876 return;
12877 }
12878 }
12879 }
12880
12881 fn re_sheet_set_index(&mut self, index: usize) {
12882 use crate::worker_route_editor::RouteEditorSheet as S;
12883 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12884 return;
12885 };
12886 match &mut ed.sheet {
12887 S::AddMenu { index: slot }
12888 | S::WaypointMenu { index: slot }
12889 | S::HarvestPicker { index: slot, .. }
12890 | S::WithdrawContainers { index: slot }
12891 | S::DepositContainers { index: slot }
12892 | S::SellNpcs { index: slot }
12893 | S::CraftBlueprint { index: slot }
12894 | S::BedPicker { index: slot }
12895 | S::FarmPlotPicker { index: slot, .. }
12896 | S::FarmPlantSeed { index: slot, .. }
12897 | S::WithdrawItems { index: slot, .. }
12898 | S::DepositFilter { index: slot, .. }
12899 | S::SellItem { index: slot, .. }
12900 | S::MarketListItem { index: slot, .. } => *slot = index,
12901 _ => {}
12902 }
12903 }
12904
12905 pub fn re_focus_sheet_filter(&mut self) {
12906 if !self.re_sheet_supports_filter() {
12907 return;
12908 }
12909 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12910 ed.sheet_filter_focused = true;
12911 }
12912 }
12913
12914 pub fn re_blur_sheet_filter_keep_text(&mut self) {
12915 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12916 return;
12917 };
12918 if !ed.sheet_filter_focused {
12919 return;
12920 }
12921 ed.sheet_filter_focused = false;
12922 self.re_sheet_clamp_index();
12923 }
12924
12925 pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
12926 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12927 return false;
12928 };
12929 if ed.sheet_filter_focused {
12930 ed.sheet_filter_focused = false;
12931 self.re_sheet_clamp_index();
12932 return true;
12933 }
12934 if !ed.sheet_filter.is_empty() {
12935 ed.sheet_filter.clear();
12936 self.re_sheet_clamp_index();
12937 return true;
12938 }
12939 false
12940 }
12941
12942 pub fn re_append_sheet_filter_char(&mut self, ch: char) {
12943 if ch.is_control() {
12944 return;
12945 }
12946 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12947 return;
12948 };
12949 if !ed.sheet_filter_focused {
12950 return;
12951 }
12952 ed.sheet_filter.push(ch);
12953 self.re_sheet_set_index(0);
12954 self.re_sheet_clamp_index();
12955 }
12956
12957 pub fn re_sheet_filter_backspace(&mut self) {
12958 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12959 return;
12960 };
12961 if !ed.sheet_filter_focused {
12962 return;
12963 }
12964 ed.sheet_filter.pop();
12965 self.re_sheet_set_index(0);
12966 self.re_sheet_clamp_index();
12967 }
12968
12969 pub fn re_sheet_row_count(&self) -> usize {
12971 use crate::worker_route_editor::{
12972 harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
12973 };
12974 let Some(ed) = self.state.worker_route_editor.as_ref() else {
12975 return 0;
12976 };
12977 match &ed.sheet {
12978 S::Stops => ed.stops.len(),
12979 S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
12980 S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
12981 S::WaypointMapPick => 0,
12982 S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
12983 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
12984 self.re_container_candidates().len()
12985 }
12986 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, .. } => {
12990 sell_item_picker_row_count(templates.len())
12991 }
12992 S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
12993 S::WaitEntry { .. } => 1,
12994 S::BedPicker { .. } => self.re_bed_candidates().len(),
12995 S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
12996 S::FarmPlantSeed { seeds, .. } => seeds.len(),
12997 }
12998 }
12999
13000 pub fn re_sheet_index(&self) -> usize {
13002 use crate::worker_route_editor::RouteEditorSheet as S;
13003 let Some(ed) = self.state.worker_route_editor.as_ref() else {
13004 return 0;
13005 };
13006 match &ed.sheet {
13007 S::AddMenu { index }
13008 | S::WaypointMenu { index }
13009 | S::HarvestPicker { index, .. }
13010 | S::WithdrawContainers { index }
13011 | S::DepositContainers { index }
13012 | S::SellNpcs { index }
13013 | S::CraftBlueprint { index }
13014 | S::BedPicker { index }
13015 | S::FarmPlotPicker { index, .. }
13016 | S::FarmPlantSeed { index, .. }
13017 | S::WithdrawItems { index, .. }
13018 | S::DepositFilter { index, .. }
13019 | S::SellItem { index, .. }
13020 | S::MarketListItem { index, .. } => *index,
13021 _ => 0,
13022 }
13023 }
13024
13025 pub fn re_sheet_move(&mut self, delta: i32) {
13027 let count = self.re_sheet_row_count();
13028 if count == 0 {
13029 return;
13030 }
13031 let cur = self.re_sheet_index();
13032 let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
13033 self.re_sheet_set_index(next);
13034 }
13035
13036 pub fn re_sheet_page(&mut self, pages: i32) {
13037 let count = self.re_sheet_row_count();
13038 if count == 0 {
13039 return;
13040 }
13041 let cur = self.re_sheet_index();
13042 let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
13043 self.re_sheet_set_index(next);
13044 }
13045
13046 pub fn re_sheet_adjust(&mut self, delta: i32) {
13048 use crate::worker_route_editor::RouteEditorSheet as S;
13049 let index = self.re_sheet_index();
13050 let Some(ed) = self.state.worker_route_editor.as_mut() else {
13051 return;
13052 };
13053 match &mut ed.sheet {
13054 S::WithdrawItems { lines, .. } => {
13055 if let Some(line) = lines.get_mut(index) {
13056 line.adjust_qty(delta);
13057 }
13058 }
13059 S::WaitEntry { ticks } => {
13060 *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
13061 }
13062 _ => {}
13063 }
13064 }
13065
13066 pub fn re_sheet_back(&mut self) {
13067 let Some(ed) = self.state.worker_route_editor.as_mut() else {
13068 return;
13069 };
13070 use crate::worker_route_editor::RouteEditorSheet as S;
13071 let was_editing = ed.editing_index.is_some();
13072 let from_top_picker = matches!(
13073 ed.sheet,
13074 S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
13075 );
13076 ed.sheet_back();
13077 if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
13078 self.state
13080 .push_log("Route: left edit sheet — press s to save current stops".to_string());
13081 }
13082 }
13083
13084 pub fn re_at_root_sheet(&self) -> bool {
13086 self.state.worker_route_editor.as_ref().is_some_and(|ed| {
13087 matches!(
13088 ed.sheet,
13089 crate::worker_route_editor::RouteEditorSheet::Stops
13090 )
13091 })
13092 }
13093
13094 pub fn re_open_add_menu(&mut self) {
13095 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13096 ed.open_add_menu();
13097 }
13098 }
13099
13100 pub fn re_open_bed_picker(&mut self) {
13101 let beds = self.re_bed_candidates();
13102 if beds.is_empty() {
13103 self.state
13104 .push_log("Route: place a camp bed first".to_string());
13105 return;
13106 }
13107 let current = self
13108 .state
13109 .worker_route_editor
13110 .as_ref()
13111 .and_then(|ed| ed.lodging_container_id.clone());
13112 let index = current
13113 .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
13114 .unwrap_or(0);
13115 self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
13116 }
13117
13118 fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
13119 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13120 ed.open_sheet(sheet);
13121 }
13122 }
13123
13124 fn re_confirm_stop(&mut self, stop: crate::worker_route_editor::WorkerRouteStop, what: String) {
13126 let appended = self
13127 .state
13128 .worker_route_editor
13129 .as_mut()
13130 .is_some_and(|ed| ed.confirm_stop(stop));
13131 if appended {
13132 self.state.push_log(format!("Route: + {what}"));
13133 } else {
13134 self.state
13135 .push_log(format!("Route: {what} already in route — selected it"));
13136 }
13137 }
13138
13139 fn re_open_withdraw_items(&mut self, container_id: String) {
13140 use crate::worker_route_editor::{
13141 RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
13142 };
13143 let contents = self.re_container_contents(&container_id);
13144 let existing = self
13148 .state
13149 .worker_route_editor
13150 .as_ref()
13151 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
13152 .and_then(|stop| match stop {
13153 WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
13154 _ => None,
13155 })
13156 .unwrap_or_default();
13157 let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
13158 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13161 let _ = ed.retarget_withdraw_container(container_id.clone());
13162 }
13163 self.re_open_sheet(S::WithdrawItems {
13164 container_id,
13165 lines,
13166 index: 0,
13167 });
13168 }
13169
13170 fn re_withdraw_items_activate(&mut self, index: usize) {
13171 use crate::worker_route_editor::{
13172 RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
13173 };
13174 enum Outcome {
13175 Cycled,
13176 Confirmed(WorkerRouteStop),
13177 Empty,
13178 }
13179 let outcome = {
13180 let Some(ed) = self.state.worker_route_editor.as_mut() else {
13181 return;
13182 };
13183 let S::WithdrawItems {
13184 container_id,
13185 lines,
13186 index: sheet_index,
13187 } = &mut ed.sheet
13188 else {
13189 return;
13190 };
13191 *sheet_index = index;
13192 if index < lines.len() {
13193 lines[index].cycle();
13194 Outcome::Cycled
13195 } else {
13196 let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
13197 if items.is_empty() {
13198 Outcome::Empty
13199 } else {
13200 let stop = WorkerRouteStop::WithdrawFrom {
13201 container_id: container_id.clone(),
13202 items,
13203 };
13204 ed.confirm_stop(stop.clone());
13205 Outcome::Confirmed(stop)
13206 }
13207 }
13208 };
13209 match outcome {
13210 Outcome::Cycled => {}
13211 Outcome::Confirmed(stop) => {
13212 let what = self.state.worker_route_stop_summary(&stop);
13213 self.state.push_log(format!("Route: + {what}"));
13214 }
13215 Outcome::Empty => self.state.push_log(
13216 "Route: pick at least one item (Space/Enter toggles All/qty)".to_string(),
13217 ),
13218 }
13219 }
13220
13221 fn re_open_deposit_filter(&mut self, container_id: String) {
13222 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13223 let existing_filter = self
13225 .state
13226 .worker_route_editor
13227 .as_ref()
13228 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
13229 .and_then(|stop| match stop {
13230 WorkerRouteStop::DepositAt { filter, .. } => {
13231 Some(filter.clone().unwrap_or_default())
13232 }
13233 _ => None,
13234 });
13235 let mut candidates = self.re_template_candidates();
13236 if let Some(ref chosen) = existing_filter {
13237 for t in chosen {
13238 if !candidates.iter().any(|c| c == t) {
13239 candidates.push(t.clone());
13240 }
13241 }
13242 candidates.sort();
13243 candidates.dedup();
13244 }
13245 let rows: Vec<(String, bool)> = match existing_filter {
13246 Some(chosen) => candidates
13247 .iter()
13248 .map(|t| (t.clone(), chosen.contains(t)))
13249 .collect(),
13250 None => candidates.into_iter().map(|t| (t, false)).collect(),
13251 };
13252 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13253 let _ = ed.retarget_deposit_container(container_id.clone());
13254 }
13255 self.re_open_sheet(S::DepositFilter {
13256 container_id,
13257 rows,
13258 index: 0,
13259 });
13260 }
13261
13262 fn re_deposit_filter_activate(&mut self, index: usize) {
13263 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13264 let mut confirmed: Option<WorkerRouteStop> = None;
13265 {
13266 let Some(ed) = self.state.worker_route_editor.as_mut() else {
13267 return;
13268 };
13269 let S::DepositFilter {
13270 container_id,
13271 rows,
13272 index: sheet_index,
13273 } = &mut ed.sheet
13274 else {
13275 return;
13276 };
13277 *sheet_index = index;
13278 if index < rows.len() {
13279 rows[index].1 = !rows[index].1;
13280 } else {
13281 let chosen: Vec<String> = rows
13283 .iter()
13284 .filter(|(_, on)| *on)
13285 .map(|(t, _)| t.clone())
13286 .collect();
13287 let filter = if chosen.is_empty() {
13288 None
13289 } else {
13290 Some(chosen)
13291 };
13292 let stop = WorkerRouteStop::DepositAt {
13293 container_id: container_id.clone(),
13294 filter,
13295 };
13296 confirmed = Some(stop.clone());
13297 ed.confirm_stop(stop);
13298 }
13299 }
13300 if let Some(stop) = confirmed {
13301 let what = self.state.worker_route_stop_summary(&stop);
13302 self.state.push_log(format!("Route: + {what}"));
13303 }
13304 }
13305
13306 fn re_open_sell_item(&mut self, npc_id: Option<String>) {
13307 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13308 let (pre_npc, pre_template, pre_all) = self
13310 .state
13311 .worker_route_editor
13312 .as_ref()
13313 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
13314 .and_then(|stop| match stop {
13315 WorkerRouteStop::TradeWith {
13316 npc_id,
13317 template,
13318 sell_all,
13319 } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
13320 _ => None,
13321 })
13322 .unwrap_or((None, None, true));
13323 let npc_id = npc_id.or(pre_npc);
13324 let mut templates = crate::worker_route_editor::sellable_route_item_template_candidates(
13325 &self.re_template_candidates(),
13326 &self.state.npcs,
13327 npc_id.as_deref(),
13328 );
13329 if let Some(template) = pre_template.as_ref() {
13332 if !templates.iter().any(|candidate| candidate == template) {
13333 templates.push(template.clone());
13334 templates.sort();
13335 }
13336 }
13337 if templates.is_empty() {
13338 let msg = crate::worker_route_editor::sell_merchant_empty_reason(
13339 npc_id.as_deref(),
13340 &self.state.npcs,
13341 &self.re_template_candidates(),
13342 );
13343 self.state.push_log(msg);
13344 return;
13345 }
13346 let mut picked = std::collections::BTreeSet::new();
13347 if let Some(t) = pre_template {
13348 picked.insert(t);
13349 }
13350 self.re_open_sheet(S::SellItem {
13351 npc_id,
13352 templates,
13353 index: if picked.is_empty() {
13354 crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
13355 } else {
13356 2
13357 },
13358 sell_all: pre_all,
13359 picked,
13360 });
13361 }
13362
13363 fn re_sell_item_activate(&mut self, index: usize) {
13364 use crate::worker_route_editor::{
13365 RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
13366 };
13367 let mut batch_log: Option<String> = None;
13368 {
13369 let Some(ed) = self.state.worker_route_editor.as_mut() else {
13370 return;
13371 };
13372 let S::SellItem {
13373 npc_id,
13374 templates,
13375 index: sheet_index,
13376 sell_all,
13377 picked,
13378 } = &mut ed.sheet
13379 else {
13380 return;
13381 };
13382 *sheet_index = index;
13383 if index == ROUTE_PICKER_DONE_ROW {
13384 if picked.is_empty() {
13385 batch_log =
13386 Some("Route: pick at least one item (Space toggles, Done confirms)".into());
13387 } else {
13388 let picks: Vec<String> = picked.iter().cloned().collect();
13389 let npc = npc_id.clone();
13390 let all = *sell_all;
13391 let added = ed.confirm_trade_picks(npc, &picks, all);
13392 batch_log = Some(format!("Route: + {added} sell stop(s)"));
13393 }
13394 } else if index == SELL_ITEM_TOGGLE_ROW {
13395 *sell_all = !*sell_all;
13396 } else if let Some(template) = templates.get(index.saturating_sub(2)) {
13397 let sellable = crate::worker_route_editor::sellable_route_item_template_candidates(
13398 std::slice::from_ref(template),
13399 &self.state.npcs,
13400 npc_id.as_deref(),
13401 )
13402 .iter()
13403 .any(|candidate| candidate == template);
13404 if !sellable && !picked.contains(template) {
13405 return;
13406 }
13407 if picked.contains(template) {
13408 picked.remove(template);
13409 } else {
13410 picked.insert(template.clone());
13411 }
13412 }
13413 }
13414 if let Some(msg) = batch_log {
13415 self.state.push_log(msg);
13416 }
13417 }
13418
13419 fn re_open_market_list_item(&mut self) {
13420 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13421 let (pre_hall, pre_template, pre_all) = self
13422 .state
13423 .worker_route_editor
13424 .as_ref()
13425 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
13426 .and_then(|stop| match stop {
13427 WorkerRouteStop::ListOnMarket {
13428 hall_id,
13429 template,
13430 list_all,
13431 } => Some((hall_id.clone(), Some(template.clone()), *list_all)),
13432 _ => None,
13433 })
13434 .unwrap_or((None, None, true));
13435 let mut templates = self.re_template_candidates();
13436 templates.sort_by_key(|t| {
13439 std::cmp::Reverse(self.state.item_base_value_copper_hint(t).unwrap_or(0))
13440 });
13441 if let Some(template) = pre_template.as_ref() {
13442 if !templates.iter().any(|c| c == template) {
13443 templates.push(template.clone());
13444 }
13445 }
13446 if templates.is_empty() {
13447 self.state
13448 .push_log("Route: no item templates available for market list".to_string());
13449 return;
13450 }
13451 let mut picked = std::collections::BTreeSet::new();
13452 if let Some(t) = pre_template {
13453 picked.insert(t);
13454 }
13455 self.re_open_sheet(S::MarketListItem {
13456 hall_id: pre_hall,
13457 templates,
13458 index: if picked.is_empty() {
13459 crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
13460 } else {
13461 2
13462 },
13463 list_all: pre_all,
13464 picked,
13465 });
13466 }
13467
13468 fn re_market_list_item_activate(&mut self, index: usize) {
13469 use crate::worker_route_editor::{
13470 RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
13471 };
13472 let mut batch_log: Option<String> = None;
13473 {
13474 let Some(ed) = self.state.worker_route_editor.as_mut() else {
13475 return;
13476 };
13477 let S::MarketListItem {
13478 hall_id,
13479 templates,
13480 index: sheet_index,
13481 list_all,
13482 picked,
13483 } = &mut ed.sheet
13484 else {
13485 return;
13486 };
13487 *sheet_index = index;
13488 if index == ROUTE_PICKER_DONE_ROW {
13489 if picked.is_empty() {
13490 batch_log =
13491 Some("Route: pick at least one item (Space toggles, Done confirms)".into());
13492 } else {
13493 let picks: Vec<String> = picked.iter().cloned().collect();
13494 let hall = hall_id.clone();
13495 let all = *list_all;
13496 let added = ed.confirm_market_list_picks(hall, &picks, all);
13497 batch_log = Some(format!("Route: + {added} market-list stop(s)"));
13498 }
13499 } else if index == SELL_ITEM_TOGGLE_ROW {
13500 *list_all = !*list_all;
13501 } else if let Some(template) = templates.get(index.saturating_sub(2)) {
13502 if picked.contains(template) {
13503 picked.remove(template);
13504 } else {
13505 picked.insert(template.clone());
13506 }
13507 }
13508 }
13509 if let Some(msg) = batch_log {
13510 self.state.push_log(msg);
13511 }
13512 }
13513
13514 pub fn re_edit_selected_stop(&mut self) {
13516 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13517 let Some(stop) = self
13518 .state
13519 .worker_route_editor
13520 .as_ref()
13521 .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
13522 else {
13523 self.state
13524 .push_log("Route: no stop selected — press a to add one".to_string());
13525 return;
13526 };
13527 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13528 ed.begin_edit_selected();
13529 }
13530 match stop {
13531 WorkerRouteStop::Waypoint { .. } => {
13532 self.re_open_sheet(S::WaypointMenu { index: 0 });
13533 }
13534 WorkerRouteStop::HarvestNode { node_id } => {
13535 let nodes = self.state.route_editor_node_candidates();
13536 if nodes.is_empty() {
13537 self.re_cancel_edit();
13538 self.state.push_log(
13539 "Route: no harvestable nodes in this region to retarget".to_string(),
13540 );
13541 } else {
13542 let mut picked = std::collections::BTreeSet::new();
13543 picked.insert(node_id.clone());
13544 let index = nodes
13545 .iter()
13546 .position(|n| n.id == node_id)
13547 .map(|i| i + 1)
13548 .unwrap_or(1);
13549 self.re_open_harvest_picker(index, picked);
13550 }
13551 }
13552 WorkerRouteStop::WithdrawFrom { container_id, .. } => {
13553 let containers = self.re_container_candidates();
13556 if containers.is_empty() {
13557 self.re_cancel_edit();
13558 self.state
13559 .push_log("Route: place a storage chest first".to_string());
13560 } else {
13561 let index = containers
13562 .iter()
13563 .position(|c| c.id == container_id)
13564 .unwrap_or(0);
13565 self.re_open_sheet(S::WithdrawContainers { index });
13566 }
13567 }
13568 WorkerRouteStop::DepositAt { container_id, .. } => {
13569 let containers = self.re_container_candidates();
13570 if containers.is_empty() {
13571 self.re_cancel_edit();
13572 self.state
13573 .push_log("Route: place a storage chest first".to_string());
13574 } else {
13575 let index = containers
13576 .iter()
13577 .position(|c| c.id == container_id)
13578 .unwrap_or(0);
13579 self.re_open_sheet(S::DepositContainers { index });
13580 }
13581 }
13582 WorkerRouteStop::TradeWith { npc_id, .. } => {
13583 let npcs = self.re_npc_candidates();
13584 let index = npc_id
13586 .as_ref()
13587 .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
13588 .unwrap_or(0);
13589 self.re_open_sheet(S::SellNpcs { index });
13590 }
13591 WorkerRouteStop::ListOnMarket { .. } => {
13592 self.re_open_market_list_item();
13593 }
13594 WorkerRouteStop::CraftAt { blueprint, .. } => {
13595 let bps = self.re_blueprint_ids();
13596 let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
13597 if bps.is_empty() {
13598 self.re_cancel_edit();
13599 self.state
13600 .push_log("Route: no known blueprints to retarget".to_string());
13601 } else {
13602 self.re_open_sheet(S::CraftBlueprint { index });
13603 }
13604 }
13605 WorkerRouteStop::CultivatePlot { .. } => {
13606 self.re_open_farm_plot_picker(
13607 crate::worker_route_editor::FarmPlotAction::Cultivate,
13608 );
13609 }
13610 WorkerRouteStop::PlantPlot { .. } => {
13611 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
13612 }
13613 WorkerRouteStop::HarvestPlot { .. } => {
13614 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
13615 }
13616 WorkerRouteStop::RestIfNeeded => {
13617 self.re_cancel_edit();
13618 self.state
13619 .push_log("Route: rest has no settings (change the bed with l)".to_string());
13620 }
13621 WorkerRouteStop::Wait { wait_ticks } => {
13622 self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
13623 }
13624 }
13625 }
13626
13627 fn re_cancel_edit(&mut self) {
13628 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13629 ed.editing_index = None;
13630 }
13631 }
13632
13633 pub fn worker_route_editor_ui_click(
13636 &mut self,
13637 click: crate::worker_route_editor::RouteEditorClick,
13638 ) {
13639 use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
13640 match click {
13641 RouteEditorClick::SelectStop(i) => {
13642 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13643 ed.sheet = S::Stops;
13644 ed.select_stop(i);
13645 }
13646 }
13647 RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
13648 RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
13649 RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
13650 }
13651 }
13652
13653 pub fn re_sheet_row_activate(&mut self, row: usize) {
13655 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13656 let Some(sheet) = self
13657 .state
13658 .worker_route_editor
13659 .as_ref()
13660 .map(|ed| ed.sheet.clone())
13661 else {
13662 return;
13663 };
13664 match sheet {
13665 S::Stops => {
13666 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13667 ed.select_stop(row);
13668 }
13669 }
13670 S::AddMenu { .. } => match row {
13671 0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
13672 1 => {
13673 if self.re_node_candidates().is_empty() {
13674 self.state
13675 .push_log("Route: no harvestable nodes in this region".to_string());
13676 } else {
13677 self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
13678 }
13679 }
13680 2 | 3 => {
13681 if self.re_container_candidates().is_empty() {
13682 self.state
13683 .push_log("Route: place a storage chest first".to_string());
13684 } else if row == 2 {
13685 self.re_open_sheet(S::WithdrawContainers { index: 0 });
13686 } else {
13687 self.re_open_sheet(S::DepositContainers { index: 0 });
13688 }
13689 }
13690 4 => {
13691 if self.re_template_candidates().is_empty() {
13692 self.state.push_log(
13693 "Route: no item templates available — learn a craft recipe or place a harvest node first"
13694 .to_string(),
13695 );
13696 } else {
13697 self.re_open_sheet(S::SellNpcs { index: 0 });
13698 }
13699 }
13700 5 => {
13701 if self.re_template_candidates().is_empty() {
13702 self.state.push_log(
13703 "Route: no item templates available — learn a craft recipe or place a harvest node first"
13704 .to_string(),
13705 );
13706 } else {
13707 self.re_open_market_list_item();
13708 }
13709 }
13710 6 => {
13711 if self.re_blueprint_ids().is_empty() {
13712 self.state.push_log(
13713 "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
13714 .to_string(),
13715 );
13716 } else {
13717 self.re_open_sheet(S::CraftBlueprint { index: 0 });
13718 }
13719 }
13720 7 => self.re_confirm_stop(
13721 WorkerRouteStop::RestIfNeeded,
13722 "rest at lodging (if needed)".into(),
13723 ),
13724 8 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
13725 9 => self.re_open_farm_plot_picker(
13726 crate::worker_route_editor::FarmPlotAction::Cultivate,
13727 ),
13728 10 => {
13729 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant)
13730 }
13731 11 => self
13732 .re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
13733 _ => {}
13734 },
13735 S::WaypointMenu { .. } => match row {
13736 0 => {
13737 let (x, y, z) = self.state.player_position_with_z();
13738 let stop = WorkerRouteStop::Waypoint { x, y, z };
13739 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
13740 }
13741 1 => {
13742 self.re_open_sheet(S::WaypointMapPick);
13743 self.state.push_log(
13744 "Route: click the map to place the waypoint (Esc to finish)".to_string(),
13745 );
13746 }
13747 _ => {}
13748 },
13749 S::HarvestPicker { .. } => {
13750 let mut log: Option<String> = None;
13751 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13752 let S::HarvestPicker {
13753 index: sheet_index,
13754 picked,
13755 nodes,
13756 } = &mut ed.sheet
13757 else {
13758 return;
13759 };
13760 *sheet_index = row;
13761 if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
13762 if picked.is_empty() {
13763 log = Some(
13764 "Route: pick at least one node (Space toggles, Done confirms)"
13765 .into(),
13766 );
13767 } else {
13768 let ids: Vec<String> = picked.iter().cloned().collect();
13769 let added = ed.confirm_harvest_picks(&ids);
13770 log = Some(format!("Route: + {added} harvest stop(s)"));
13771 }
13772 } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
13773 if picked.contains(&n.id) {
13774 picked.remove(&n.id);
13775 } else {
13776 picked.insert(n.id.clone());
13777 }
13778 }
13779 }
13780 if let Some(msg) = log {
13781 self.state.push_log(msg);
13782 }
13783 }
13784 S::WithdrawContainers { .. } => {
13785 let containers = self.re_container_candidates();
13786 if let Some(c) = containers.get(row) {
13787 let id = c.id.clone();
13788 self.re_open_withdraw_items(id);
13789 }
13790 }
13791 S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
13792 S::DepositContainers { .. } => {
13793 let containers = self.re_container_candidates();
13794 if let Some(c) = containers.get(row) {
13795 let id = c.id.clone();
13796 self.re_open_deposit_filter(id);
13797 }
13798 }
13799 S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
13800 S::SellNpcs { .. } => {
13801 let templates = self.re_template_candidates();
13802 let npcs = self.re_npc_candidates();
13803 if row == 0 {
13804 if !crate::worker_route_editor::any_trade_npc_buys_route_item(
13805 &self.state.npcs,
13806 &templates,
13807 ) {
13808 self.state.push_log(
13809 crate::worker_route_editor::sell_merchant_empty_reason(
13810 None,
13811 &self.state.npcs,
13812 &templates,
13813 ),
13814 );
13815 return;
13816 }
13817 self.re_open_sell_item(None);
13818 return;
13819 }
13820 let Some(n) = npcs.get(row - 1) else {
13821 return;
13822 };
13823 if !n.buys_route_item {
13824 self.state
13825 .push_log(crate::worker_route_editor::sell_merchant_empty_reason(
13826 Some(n.id.as_str()),
13827 &self.state.npcs,
13828 &templates,
13829 ));
13830 return;
13831 }
13832 self.re_open_sell_item(Some(n.id.clone()));
13833 }
13834 S::SellItem { .. } => self.re_sell_item_activate(row),
13835 S::MarketListItem { .. } => self.re_market_list_item_activate(row),
13836 S::CraftBlueprint { .. } => {
13837 let bps = self.re_blueprint_ids();
13838 if let Some(bp) = bps.get(row) {
13839 let stop = WorkerRouteStop::CraftAt {
13840 device: "hand".into(),
13841 blueprint: bp.clone(),
13842 qty: None,
13843 };
13844 self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
13845 }
13846 }
13847 S::WaitEntry { ticks } => {
13848 let stop = WorkerRouteStop::Wait { wait_ticks: ticks };
13849 self.re_confirm_stop(stop, format!("wait {ticks}t"));
13850 }
13851 S::BedPicker { .. } => {
13852 let beds = self.re_bed_candidates();
13853 if let Some((id, name)) = beds.get(row) {
13854 let (id, name) = (id.clone(), name.clone());
13855 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13856 ed.lodging_container_id = Some(id.clone());
13857 ed.sheet = S::Stops;
13858 }
13859 self.state
13860 .push_log(format!("Route: rest bed set to {name}"));
13861 }
13862 }
13863 S::FarmPlotPicker { action, .. } => {
13864 let plots = self.re_farm_plot_candidates();
13865 let Some(plot) = plots.get(row).cloned() else {
13866 return;
13867 };
13868 match action {
13869 crate::worker_route_editor::FarmPlotAction::Cultivate => {
13870 let label = plot_route_label(&plot);
13871 self.re_confirm_stop(
13872 WorkerRouteStop::CultivatePlot {
13873 plot_id: plot.plot_id,
13874 },
13875 format!("cultivate {label}"),
13876 );
13877 }
13878 crate::worker_route_editor::FarmPlotAction::Harvest => {
13879 let label = plot_route_label(&plot);
13880 self.re_confirm_stop(
13881 WorkerRouteStop::HarvestPlot {
13882 plot_id: plot.plot_id,
13883 },
13884 format!("harvest {label}"),
13885 );
13886 }
13887 crate::worker_route_editor::FarmPlotAction::Plant => {
13888 let seeds = self.re_farm_seed_candidates();
13889 if seeds.is_empty() {
13890 self.state.push_log(
13891 "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
13892 );
13893 return;
13894 }
13895 self.re_open_sheet(S::FarmPlantSeed {
13896 plot_id: plot.plot_id,
13897 seeds,
13898 index: 0,
13899 });
13900 }
13901 }
13902 }
13903 S::FarmPlantSeed { plot_id, seeds, .. } => {
13904 if let Some(seed) = seeds.get(row).cloned() {
13905 self.re_confirm_stop(
13906 WorkerRouteStop::PlantPlot {
13907 plot_id,
13908 seed_template: seed.clone(),
13909 },
13910 format!("plant {seed}"),
13911 );
13912 }
13913 }
13914 S::WaypointMapPick => {}
13915 }
13916 }
13917
13918 fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
13919 use crate::worker_route_editor::RouteEditorSheet as S;
13920 if self.re_farm_plot_candidates().is_empty() {
13921 self.state
13922 .push_log("Route: no farmable plots visible — claim land or get farm access first");
13923 return;
13924 }
13925 self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
13926 }
13927
13928 fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
13929 self.state
13930 .property_plots
13931 .iter()
13932 .filter(|p| p.is_mine || p.may_farm)
13933 .cloned()
13934 .collect()
13935 }
13936
13937 fn re_farm_seed_candidates(&self) -> Vec<String> {
13941 let mut set = std::collections::BTreeSet::new();
13942 let looks_like_seed =
13943 |id: &str, catalog: &std::collections::HashMap<String, ItemCatalogEntryView>| {
13944 catalog.get(id).is_some_and(|e| e.is_farm_seed()) || id.ends_with("_seed")
13945 };
13946 for (id, _, _) in self.state.farm_seed_entries() {
13947 set.insert(id);
13948 }
13949 for c in &self.state.placed_containers {
13950 let mine = match (self.state.character_id, c.owner_character_id) {
13951 (Some(a), Some(b)) => a == b,
13952 _ => false,
13953 };
13954 if !mine {
13955 continue;
13956 }
13957 for s in &c.contents {
13958 if s.quantity > 0
13959 && (s.props.contains_key("seed_for")
13960 || looks_like_seed(&s.template_id, &self.state.item_catalog))
13961 {
13962 set.insert(s.template_id.clone());
13963 }
13964 }
13965 }
13966 if let Some(ed) = self.state.worker_route_editor.as_ref() {
13967 for stop in &ed.stops {
13968 if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } =
13969 stop
13970 {
13971 for it in items {
13972 if looks_like_seed(&it.template, &self.state.item_catalog) {
13973 set.insert(it.template.clone());
13974 }
13975 }
13976 }
13977 if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
13978 seed_template,
13979 ..
13980 } = stop
13981 {
13982 if !seed_template.is_empty() {
13983 set.insert(seed_template.clone());
13984 }
13985 }
13986 }
13987 }
13988 for (id, entry) in &self.state.item_catalog {
13989 if entry.is_farm_seed() {
13990 set.insert(id.clone());
13991 }
13992 }
13993 set.into_iter().collect()
13994 }
13995
13996 pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
14003 use crate::worker_route_editor as wre;
14004 use wre::RouteEditorSheet as S;
14005 if self.state.worker_route_editor.is_none() {
14006 return;
14007 }
14008 let sheet = self
14009 .state
14010 .worker_route_editor
14011 .as_ref()
14012 .map(|ed| ed.sheet.clone())
14013 .unwrap_or(S::Stops);
14014 match sheet {
14015 S::WaypointMapPick => {
14016 let (_, _, z) = self.state.player_position_with_z();
14017 let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
14018 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
14019 let editing = self
14021 .state
14022 .worker_route_editor
14023 .as_ref()
14024 .is_some_and(|ed| ed.editing_index.is_some());
14025 if !editing {
14026 if let Some(ed) = self.state.worker_route_editor.as_mut() {
14027 ed.sheet = S::WaypointMapPick;
14028 }
14029 }
14030 }
14031 S::HarvestPicker { .. } => {
14032 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
14033 let mut log: Option<String> = None;
14034 if let Some(ed) = self.state.worker_route_editor.as_mut() {
14035 let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
14036 return;
14037 };
14038 let selected = if picked.contains(&node.id) {
14039 picked.remove(&node.id);
14040 false
14041 } else {
14042 picked.insert(node.id.clone());
14043 true
14044 };
14045 log = Some(format!(
14046 "Route: {} {}",
14047 if selected { "selected" } else { "deselected" },
14048 resource_node_route_label(node)
14049 ));
14050 }
14051 if let Some(msg) = log {
14052 self.state.push_log(msg);
14053 }
14054 }
14055 }
14056 S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
14057 let inside = self.state.effective_inside_building();
14059 if let Some(cid) = wre::pick_storage_container_at(
14060 &self.state.placed_containers,
14061 self.state.character_id,
14062 x,
14063 y,
14064 inside.as_deref(),
14065 ) {
14066 self.re_open_withdraw_items(cid);
14067 }
14068 }
14069 S::DepositContainers { .. } | S::DepositFilter { .. } => {
14070 let inside = self.state.effective_inside_building();
14071 if let Some(cid) = wre::pick_storage_container_at(
14072 &self.state.placed_containers,
14073 self.state.character_id,
14074 x,
14075 y,
14076 inside.as_deref(),
14077 ) {
14078 self.re_open_deposit_filter(cid);
14079 }
14080 }
14081 S::SellNpcs { .. } => {
14082 if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
14083 self.re_open_sell_item(Some(npc_id));
14084 }
14085 }
14086 S::SellItem { .. } => {
14087 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
14088 if let Some(ed) = self.state.worker_route_editor.as_mut() {
14089 if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
14090 *slot = Some(npc_id.clone());
14091 }
14092 }
14093 self.state
14094 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
14095 }
14096 }
14097 _ => self.worker_route_editor_quick_add_click(x, y),
14099 }
14100 }
14101
14102 fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
14106 use crate::worker_route_editor as wre;
14107 let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
14108 let dx = ax - bx;
14109 let dy = ay - by;
14110 (dx * dx + dy * dy).sqrt()
14111 };
14112
14113 let selected_stop_kind = self
14116 .state
14117 .worker_route_editor
14118 .as_ref()
14119 .and_then(|ed| ed.stops.get(ed.selected_stop_index))
14120 .map(|s| match s {
14121 wre::WorkerRouteStop::TradeWith { .. } => 1,
14122 wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
14123 _ => 0,
14124 })
14125 .unwrap_or(0);
14126 if selected_stop_kind == 1 {
14127 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
14128 if let Some(ed) = self.state.worker_route_editor.as_mut() {
14129 ed.set_selected_trade_npc(npc_id.clone());
14130 }
14131 self.state
14132 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
14133 return;
14134 }
14135 }
14136 if selected_stop_kind == 2 {
14137 let inside = self.state.effective_inside_building();
14138 if let Some(cid) = wre::pick_storage_container_at(
14139 &self.state.placed_containers,
14140 self.state.character_id,
14141 x,
14142 y,
14143 inside.as_deref(),
14144 ) {
14145 let name = self
14146 .state
14147 .placed_containers
14148 .iter()
14149 .find(|c| c.id == cid)
14150 .map(|c| c.display_name.clone())
14151 .unwrap_or_else(|| "container".into());
14152 if let Some(ed) = self.state.worker_route_editor.as_mut() {
14153 ed.set_selected_withdraw_container(cid.clone());
14154 }
14155 self.state
14156 .push_log(format!("Route: withdraw source → {name}"));
14157 return;
14158 }
14159 }
14160
14161 enum Target {
14164 Bed(String),
14165 Container(String),
14166 Npc(String, String),
14167 Node(String, String),
14168 }
14169 let mut best: Option<(f32, u8, Target)> = None;
14170 let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
14171 let better = match best {
14172 None => true,
14173 Some((bd, brank, _)) => {
14174 d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank)
14175 }
14176 };
14177 if better {
14178 *best = Some((d, rank, t));
14179 }
14180 };
14181 let inside = self.state.effective_inside_building();
14182 if let Some(bed_id) = wre::pick_lodging_container_at(
14183 &self.state.placed_containers,
14184 self.state.character_id,
14185 x,
14186 y,
14187 inside.as_deref(),
14188 ) {
14189 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
14190 let already_bed =
14193 self.state.worker_route_editor.as_ref().is_some_and(|ed| {
14194 ed.lodging_container_id.as_deref() == Some(bed_id.as_str())
14195 });
14196 if already_bed {
14197 consider(
14198 dist(x, y, c.x, c.y),
14199 1,
14200 Target::Container(bed_id),
14201 &mut best,
14202 );
14203 } else {
14204 consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
14205 }
14206 }
14207 }
14208 if let Some(cid) = wre::pick_storage_container_at(
14209 &self.state.placed_containers,
14210 self.state.character_id,
14211 x,
14212 y,
14213 inside.as_deref(),
14214 ) {
14215 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
14216 consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
14217 }
14218 }
14219 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
14220 if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
14221 consider(
14222 dist(x, y, n.x, n.y),
14223 2,
14224 Target::Npc(npc_id, label),
14225 &mut best,
14226 );
14227 }
14228 }
14229 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
14230 let d = dist(x, y, node.x, node.y);
14231 let label = resource_node_route_label(node);
14232 consider(d, 3, Target::Node(node.id.clone(), label), &mut best);
14233 }
14234
14235 match best.map(|(_, _, t)| t) {
14236 Some(Target::Bed(bed_id)) => {
14237 let name = self
14238 .state
14239 .placed_containers
14240 .iter()
14241 .find(|c| c.id == bed_id)
14242 .map(|c| c.display_name.clone())
14243 .unwrap_or_else(|| "camp bed".into());
14244 if let Some(ed) = self.state.worker_route_editor.as_mut() {
14245 ed.lodging_container_id = Some(bed_id.clone());
14246 }
14247 self.state
14248 .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
14249 }
14250 Some(Target::Container(cid)) => {
14251 let name = self
14252 .state
14253 .placed_containers
14254 .iter()
14255 .find(|c| c.id == cid)
14256 .map(|c| c.display_name.clone())
14257 .unwrap_or_else(|| "container".into());
14258 let added = self
14259 .state
14260 .worker_route_editor
14261 .as_mut()
14262 .is_some_and(|ed| ed.append_deposit_at(&cid));
14263 if added {
14264 self.state
14265 .push_log(format!("Route: + deposit at {name} ({cid})"));
14266 } else {
14267 self.state.push_log(format!(
14268 "Route: {name} already in route — selected it (d to remove)"
14269 ));
14270 }
14271 }
14272 Some(Target::Npc(npc_id, label)) => {
14273 let template = self.re_template_candidates().into_iter().next();
14276 let Some(template) = template else {
14277 self.state.push_log(
14278 "Route: no items in your storage to sell — stock a chest first".to_string(),
14279 );
14280 return;
14281 };
14282 let added = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
14283 ed.append_trade_with(template.clone(), Some(npc_id.clone()), true)
14284 });
14285 if added {
14286 self.state
14287 .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
14288 } else {
14289 self.state.push_log(format!(
14290 "Route: {label} already sells {template} — selected it (d to remove)"
14291 ));
14292 }
14293 }
14294 Some(Target::Node(id, label)) => {
14295 let added = self
14296 .state
14297 .worker_route_editor
14298 .as_mut()
14299 .is_some_and(|ed| ed.append_harvest_node(&id));
14300 if added {
14301 self.state
14302 .push_log(format!("Route: + harvest node {label}"));
14303 } else {
14304 self.state.push_log(format!(
14305 "Route: {label} already in route — selected it (d to remove)"
14306 ));
14307 }
14308 }
14309 None => {}
14310 }
14311 }
14312
14313 pub fn worker_route_editor_select(&mut self, delta: i32) {
14314 let Some(ed) = self.state.worker_route_editor.as_mut() else {
14315 return;
14316 };
14317 if ed.stops.is_empty() {
14318 return;
14319 }
14320 let n = ed.stops.len() as i32;
14321 let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
14322 ed.selected_stop_index = next;
14323 }
14324
14325 pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
14326 let Some(ed) = self.state.worker_route_editor.as_mut() else {
14327 return;
14328 };
14329 if delta < 0 {
14330 ed.move_selected_up();
14331 } else if delta > 0 {
14332 ed.move_selected_down();
14333 }
14334 }
14335
14336 pub fn worker_route_editor_delete_selected(&mut self) {
14337 let removed = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
14338 let before = ed.stop_count();
14339 ed.remove_selected_stop();
14340 ed.stop_count() < before
14341 });
14342 if removed {
14343 self.state.push_log("Route: removed selected stop");
14344 }
14345 }
14346
14347 pub fn worker_route_editor_clear_stops(&mut self) {
14350 let Some(ed) = self.state.worker_route_editor.as_mut() else {
14351 return;
14352 };
14353 if ed.stops.is_empty() {
14354 self.state
14355 .push_log("Route: already empty — s saves an idle worker".to_string());
14356 return;
14357 }
14358 ed.stops.clear();
14359 ed.selected_stop_index = 0;
14360 self.state.push_log(
14361 "Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string(),
14362 );
14363 }
14364
14365 pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
14366 if self.state.pending_worker_job_ack.is_some() {
14367 anyhow::bail!("route save still pending — wait for server ack");
14368 }
14369 let Some(ed) = self.state.worker_route_editor.clone() else {
14370 anyhow::bail!("route editor not open");
14371 };
14372 let (job_yaml, idle) = if ed.stops.is_empty() {
14375 (ed.build_idle_job_yaml(), true)
14376 } else {
14377 (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
14378 };
14379 let worker_id = ed.worker_instance_id.clone();
14380 let route_view = if idle { None } else { Some(ed.to_route_view()) };
14381 let mode = if idle {
14382 flatland_protocol::WorkerModeView::Idle
14383 } else {
14384 flatland_protocol::WorkerModeView::JobLoop
14385 };
14386 let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
14387 .state
14388 .hired_workers
14389 .iter()
14390 .find(|w| w.instance_id == worker_id)
14391 .map(|w| {
14392 (
14393 w.route.clone(),
14394 w.mode,
14395 w.step_label.clone(),
14396 w.last_error.clone(),
14397 )
14398 })
14399 .unwrap_or((
14400 None,
14401 flatland_protocol::WorkerModeView::Idle,
14402 String::new(),
14403 None,
14404 ));
14405 self.seq += 1;
14406 let seq = self.seq;
14407 self.session
14408 .submit_intent(Intent::SetWorkerJob {
14409 entity_id: self.state.entity_id,
14410 worker_instance_id: worker_id.clone(),
14411 job_yaml,
14412 seq,
14413 })
14414 .await?;
14415 self.state.intents_sent += 1;
14416 if let Some(w) = self
14417 .state
14418 .hired_workers
14419 .iter_mut()
14420 .find(|w| w.instance_id == worker_id)
14421 {
14422 w.route = route_view;
14423 w.mode = mode;
14424 w.last_error = None;
14425 if idle {
14426 w.step_label.clear();
14427 w.route_stop_index = None;
14428 }
14429 }
14430 self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
14431 seq,
14432 worker_instance_id: worker_id,
14433 worker_label: ed.worker_label.clone(),
14434 idle,
14435 stop_count: ed.stops.len(),
14436 prev_route,
14437 prev_mode,
14438 prev_step_label,
14439 prev_last_error,
14440 });
14441 self.state.push_log(format!(
14442 "Route: saving for {}… (waiting for server)",
14443 ed.worker_label
14444 ));
14445 Ok(())
14447 }
14448 pub fn quest_menu_move(&mut self, delta: i32) {
14449 let n = self.state.active_quest_entries().len();
14450 if n == 0 {
14451 return;
14452 }
14453 let idx = self.state.quest_menu_index as i32;
14454 self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
14455 }
14456
14457 pub fn quest_menu_page(&mut self, pages: i32) {
14458 let n = self.state.active_quest_entries().len();
14459 self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
14460 }
14461
14462 pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
14463 let Some(offer) = self.state.selected_quest_offer().cloned() else {
14464 anyhow::bail!("no quest offer");
14465 };
14466 self.seq += 1;
14467 let seq = self.seq;
14468 self.session
14469 .submit_intent(Intent::AcceptQuest {
14470 entity_id: self.state.entity_id,
14471 quest_id: offer.quest_id,
14472 seq,
14473 })
14474 .await?;
14475 self.state.intents_sent += 1;
14476 Ok(())
14477 }
14478
14479 pub fn quest_offer_move(&mut self, delta: i32) {
14480 self.state.move_quest_offer_selection(delta);
14481 }
14482
14483 pub fn quest_offer_decline(&mut self) {
14484 self.state.clear_quest_offers();
14485 if !self.state.show_npc_chat
14486 && !self.state.show_shop_menu
14487 && self.state.npc_verb_target.is_some()
14488 {
14489 self.state.show_npc_verb_menu = true;
14490 }
14491 }
14492
14493 pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
14494 if !self.state.show_quest_menu {
14495 return Ok(());
14496 }
14497 let active: Vec<_> = self
14498 .state
14499 .active_quest_entries()
14500 .into_iter()
14501 .cloned()
14502 .collect();
14503 let Some(entry) = active.get(self.state.quest_menu_index) else {
14504 return Ok(());
14505 };
14506 if self.state.quest_withdraw_confirm {
14507 if !entry.can_withdraw {
14508 anyhow::bail!("quest cannot be withdrawn");
14509 }
14510 self.seq += 1;
14511 let seq = self.seq;
14512 self.session
14513 .submit_intent(Intent::WithdrawQuest {
14514 entity_id: self.state.entity_id,
14515 quest_id: entry.quest_id.clone(),
14516 seq,
14517 })
14518 .await?;
14519 self.state.intents_sent += 1;
14520 self.state.quest_withdraw_confirm = false;
14521 return Ok(());
14522 }
14523 self.seq += 1;
14524 let seq = self.seq;
14525 self.session
14526 .submit_intent(Intent::TrackQuest {
14527 entity_id: self.state.entity_id,
14528 quest_id: entry.quest_id.clone(),
14529 seq,
14530 })
14531 .await?;
14532 self.state.intents_sent += 1;
14533 Ok(())
14534 }
14535
14536 pub fn quest_request_withdraw(&mut self) {
14537 if self.state.show_quest_menu {
14538 self.state.quest_withdraw_confirm = true;
14539 }
14540 }
14541
14542 pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
14543 if !self.state.is_alive() {
14544 anyhow::bail!("you are dead");
14545 }
14546 let Some(catalog) = self.state.shop_catalog.clone() else {
14547 anyhow::bail!("no shop open");
14548 };
14549 self.seq += 1;
14550 let seq = self.seq;
14551 match self.state.shop_tab {
14552 ShopTab::Buy => {
14553 let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
14554 anyhow::bail!("nothing selected");
14555 };
14556 if offer.already_owned {
14557 anyhow::bail!("already owned");
14558 }
14559 self.session
14560 .submit_intent(Intent::ShopBuy {
14561 entity_id: self.state.entity_id,
14562 npc_id: catalog.npc_id.clone(),
14563 offer_id: offer.offer_id.clone(),
14564 quantity: self.state.shop_quantity,
14565 seq,
14566 })
14567 .await?;
14568 }
14569 ShopTab::Sell => {
14570 let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
14571 anyhow::bail!("nothing to sell");
14572 };
14573 if line.quantity == 0 {
14574 anyhow::bail!("you have no {}", line.label);
14575 }
14576 let quantity = self.state.shop_quantity.min(line.quantity).max(1);
14577 self.session
14578 .submit_intent(Intent::ShopSell {
14579 entity_id: self.state.entity_id,
14580 npc_id: catalog.npc_id.clone(),
14581 template_id: line.template_id.clone(),
14582 quantity,
14583 seq,
14584 })
14585 .await?;
14586 }
14587 }
14588 self.state.intents_sent += 1;
14589 Ok(())
14590 }
14591
14592 pub fn craft_menu_move(&mut self, delta: i32) {
14593 let n = self.state.craft_filtered_indices().len();
14594 if n == 0 {
14595 return;
14596 }
14597 let idx = self.state.craft_menu_index as i32;
14598 let next = (idx + delta).rem_euclid(n as i32);
14599 self.state.craft_menu_index = next as usize;
14600 self.state.clamp_craft_batch_quantity();
14601 }
14602
14603 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
14604 self.state.craft_batch_adjust_quantity(delta);
14605 }
14606
14607 pub fn craft_batch_set_max(&mut self) {
14608 self.state.craft_batch_set_max();
14609 }
14610
14611 pub fn craft_batch_set_min(&mut self) {
14612 self.state.craft_batch_set_min();
14613 }
14614
14615 pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
14616 let Some(blueprint) = self.state.craft_selected_blueprint().cloned() else {
14617 anyhow::bail!("no blueprints in this tab");
14618 };
14619 if !self.state.can_craft_blueprint(&blueprint) {
14620 let hint = self
14621 .state
14622 .craft_missing_hint(&blueprint)
14623 .unwrap_or_else(|| "missing materials".into());
14624 anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
14625 }
14626 let count = self.state.craft_batch_quantity;
14627 let max = self.state.max_craft_batches(&blueprint);
14628 if max == 0 {
14629 anyhow::bail!("cannot craft {}", blueprint.label);
14630 }
14631 let batches = count.min(max);
14632 self.craft(&blueprint.id, Some(batches)).await?;
14633 Ok(())
14635 }
14636
14637 pub async fn move_by(
14638 &mut self,
14639 forward: f32,
14640 strafe: f32,
14641 vertical: f32,
14642 sprint: bool,
14643 sneak: bool,
14644 ) -> anyhow::Result<()> {
14645 if !self.state.is_alive() {
14646 anyhow::bail!("you are dead");
14647 }
14648 if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
14649 self.last_move_forward = forward;
14650 self.last_move_strafe = strafe;
14651 }
14652 self.seq += 1;
14653 self.session
14654 .submit_intent(Intent::Move {
14655 entity_id: self.state.entity_id,
14656 forward,
14657 strafe,
14658 vertical,
14659 sprint: sprint && !sneak,
14660 sneak,
14661 seq: self.seq,
14662 })
14663 .await?;
14664 self.state.intents_sent += 1;
14665 Ok(())
14666 }
14667
14668 pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
14669 if !self.state.connected {
14670 crate::harvest_trace!("harvest_nearest rejected: not connected");
14671 anyhow::bail!("not connected");
14672 }
14673 if !self.state.is_alive() {
14674 crate::harvest_trace!("harvest_nearest rejected: player dead");
14675 anyhow::bail!("you are dead");
14676 }
14677 if self.state.harvest_in_progress {
14678 if self.state.harvest_state_stale() {
14679 self.state.clear_harvest_state();
14680 } else {
14681 anyhow::bail!("already harvesting");
14682 }
14683 }
14684 let (px, py) = self
14685 .state
14686 .player
14687 .as_ref()
14688 .map(|p| (p.transform.position.x, p.transform.position.y))
14689 .unwrap_or((0.0, 0.0));
14690
14691 let available = self
14692 .state
14693 .resource_nodes
14694 .iter()
14695 .filter(|n| !n.harvest_off)
14696 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
14697 .count();
14698 let node_id = self
14699 .state
14700 .resource_nodes
14701 .iter()
14702 .filter(|n| !n.harvest_off)
14703 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
14704 .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
14705 .min_by(|a, b| {
14706 let da = distance(px, py, a.x, a.y);
14707 let db = distance(px, py, b.x, b.y);
14708 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
14709 })
14710 .map(|n| n.id.clone());
14711
14712 let Some(node_id) = node_id else {
14713 let has_loot = self
14714 .state
14715 .ground_drops
14716 .iter()
14717 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
14718 if has_loot {
14719 return self.pickup_nearest().await;
14720 }
14721 anyhow::bail!(
14722 "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
14723 );
14724 };
14725
14726 self.seq += 1;
14727 let seq = self.seq;
14728 crate::harvest_trace!(
14729 entity_id = self.state.entity_id,
14730 node_id = %node_id,
14731 seq,
14732 px,
14733 py,
14734 available_nodes = available,
14735 "submitting harvest intent"
14736 );
14737 self.session
14738 .submit_intent(Intent::Harvest {
14739 entity_id: self.state.entity_id,
14740 node_id,
14741 seq,
14742 })
14743 .await?;
14744 self.state.intents_sent += 1;
14745 self.state.harvest_in_progress = true;
14746 self.state.harvest_started_at = Some(Instant::now());
14747 self.state.push_log("Harvesting…");
14748 crate::harvest_trace!(
14749 entity_id = self.state.entity_id,
14750 seq,
14751 "harvest intent queued to session"
14752 );
14753 Ok(())
14754 }
14755
14756 pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
14757 if !self.state.is_alive() {
14758 anyhow::bail!("you are dead");
14759 }
14760 let blueprint_id = self
14761 .state
14762 .blueprints
14763 .iter()
14764 .find(|bp| self.state.can_craft_blueprint(bp))
14765 .map(|bp| bp.id.clone())
14766 .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
14767 self.craft(&blueprint_id, None).await
14768 }
14769
14770 pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
14771 if !self.state.is_alive() {
14772 anyhow::bail!("you are dead");
14773 }
14774 self.seq += 1;
14775 self.session
14776 .submit_intent(Intent::Craft {
14777 entity_id: self.state.entity_id,
14778 blueprint_id: blueprint_id.to_string(),
14779 count,
14780 seq: self.seq,
14781 })
14782 .await?;
14783 self.state.intents_sent += 1;
14784 let (label, batches) = self
14785 .state
14786 .blueprints
14787 .iter()
14788 .find(|b| b.id == blueprint_id)
14789 .map(|b| {
14790 let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
14791 (b.label.as_str(), n)
14792 })
14793 .unwrap_or((blueprint_id, count.unwrap_or(1)));
14794 self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
14795 self.state.craft_channel_blueprint_id = Some(blueprint_id.to_string());
14796 self.state.craft_channel_seen = false;
14797 Ok(())
14798 }
14799
14800 pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
14801 if !self.state.is_alive() {
14802 anyhow::bail!("you are dead");
14803 }
14804 let target_id = if let Some(primary) = self.state.probe_use_world().primary {
14805 if primary.kind.cascade_stage() == 0 && primary.in_range {
14806 primary.id
14807 } else {
14808 match self.state.nearest_interact_target() {
14809 Some(id) => id,
14810 None => anyhow::bail!("nothing to interact with nearby"),
14811 }
14812 }
14813 } else {
14814 match self.state.nearest_interact_target() {
14815 Some(id) => id,
14816 None => anyhow::bail!("nothing to interact with nearby"),
14817 }
14818 };
14819 if self.state.npcs.iter().any(|n| n.id == target_id) {
14820 self.state.show_npc_verb_menu = true;
14821 self.state.npc_verb_target = Some(target_id);
14822 self.state.npc_verb_index = 0;
14823 self.state.npc_verb_notice = None;
14824 return Ok(());
14825 }
14826 if self
14827 .state
14828 .hired_workers
14829 .iter()
14830 .any(|w| w.instance_id == target_id)
14831 {
14832 return self.open_workers_menu_for(&target_id).await;
14833 }
14834 if let Ok(peer_id) = target_id.parse::<EntityId>() {
14835 if self
14836 .state
14837 .hired_workers
14838 .iter()
14839 .any(|w| w.entity_id == peer_id)
14840 {
14841 if let Some(w) = self
14842 .state
14843 .hired_workers
14844 .iter()
14845 .find(|w| w.entity_id == peer_id)
14846 {
14847 let id = w.instance_id.clone();
14848 return self.open_workers_menu_for(&id).await;
14849 }
14850 }
14851 if let Some(entity) = self
14852 .state
14853 .entities
14854 .iter()
14855 .find(|e| e.id == peer_id && e.id != self.state.entity_id)
14856 {
14857 self.state.player_verbs.open_for(peer_id, &entity.label);
14858 return Ok(());
14859 }
14860 }
14861 self.seq += 1;
14862 self.session
14863 .submit_intent(Intent::Interact {
14864 entity_id: self.state.entity_id,
14865 target_id: target_id.clone(),
14866 seq: self.seq,
14867 })
14868 .await?;
14869 self.state.intents_sent += 1;
14870 Ok(())
14871 }
14872
14873 pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
14878 if !self.state.is_alive() {
14879 anyhow::bail!("you are dead");
14880 }
14881 let (px, py) = self.state.player_position();
14882 let has_loot = self
14883 .state
14884 .ground_drops
14885 .iter()
14886 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
14887 if has_loot {
14888 return self.pickup_nearest().await;
14889 }
14890
14891 if let Some(primary) = self.state.probe_use_world().primary {
14893 match primary.kind.cascade_stage() {
14894 0 => return self.interact_nearest().await,
14895 2 => return self.pickup_nearest_container().await,
14896 3 => return self.harvest_nearest().await,
14897 _ => {}
14898 }
14899 }
14900
14901 if let Some(plot) = self.state.my_plot_under_player().cloned() {
14902 const SELL_WINDOW: Duration = Duration::from_millis(1200);
14904 let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
14905 && self
14906 .state
14907 .sell_plot_armed_at
14908 .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
14909 if sell_armed {
14910 return self.confirm_sell_plot_to_crown(plot.plot_id).await;
14911 }
14912 self.state.sell_plot_confirm = None;
14913 self.state.sell_plot_armed_at = None;
14914
14915 let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
14918 self.state.npcs.iter().any(|n| n.id == id)
14919 || self.state.hired_workers.iter().any(|w| w.instance_id == id)
14920 || self.state.doors.iter().any(|d| d.id == id)
14921 || self.state.interactables.iter().any(|i| {
14922 i.id == id
14923 && matches!(i.kind.as_str(), "quest_board" | "well" | "exit" | "enter")
14924 })
14925 || id.parse::<EntityId>().is_ok_and(|eid| {
14926 self.state
14927 .entities
14928 .iter()
14929 .any(|e| e.id == eid && e.id != self.state.entity_id)
14930 })
14931 });
14932 if !blocking_interact {
14933 match self.harvest_nearest().await {
14935 Ok(()) => return Ok(()),
14936 Err(err) => {
14937 let msg = err.to_string();
14938 if !(msg.contains("no harvestable")
14939 || msg.contains("press p")
14940 || msg.contains("press f")
14941 || msg.contains("nothing"))
14942 {
14943 return Err(err);
14944 }
14945 }
14946 }
14947 return Ok(());
14948 }
14949 }
14950 if self.state.nearest_interact_target().is_some() {
14951 return self.interact_nearest().await;
14952 }
14953 if let Some((label, dist)) = self.state.nearest_quest_board() {
14956 if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
14957 anyhow::bail!(
14958 "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
14959 );
14960 }
14961 }
14962
14963 match self.harvest_nearest().await {
14964 Ok(()) => Ok(()),
14965 Err(err) => {
14966 let msg = err.to_string();
14967 if msg.contains("no harvestable")
14968 || msg.contains("press p")
14969 || msg.contains("press f")
14970 {
14971 anyhow::bail!(
14972 "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
14973 );
14974 }
14975 Err(err)
14976 }
14977 }
14978 }
14979
14980 pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
14982 if !self.state.is_alive() {
14983 anyhow::bail!("you are dead");
14984 }
14985 if self.state.claim_mode.is_some() {
14986 anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
14987 }
14988 let zone = self
14989 .state
14990 .free_property_zone_under_player()
14991 .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
14992 let zone_id = zone.id.clone();
14993 let label = zone
14994 .label
14995 .as_deref()
14996 .filter(|s| !s.trim().is_empty())
14997 .unwrap_or(zone.id.as_str())
14998 .to_string();
14999 self.enter_claim_mode(&zone_id);
15000 self.state.push_log(format!(
15001 "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
15002 ));
15003 Ok(())
15004 }
15005
15006 pub fn enter_claim_mode(&mut self, zone_id: &str) {
15008 let Some(zone) = self
15009 .state
15010 .property_zones
15011 .iter()
15012 .find(|z| z.id == zone_id)
15013 .cloned()
15014 else {
15015 self.state.push_log("unknown property zone");
15016 return;
15017 };
15018 self.state.sell_plot_confirm = None;
15019 self.state.sell_plot_armed_at = None;
15020 let min_area = self
15021 .state
15022 .property_plot_settings
15023 .as_ref()
15024 .map(|s| s.min_plot_area_m2)
15025 .unwrap_or(4.0)
15026 .max(1.0);
15027 let min_side = min_area.sqrt().ceil().max(1.0) as u32;
15028 let side = 4u32.max(min_side);
15029 let (px, py) = self.state.player_position();
15030 let anchor_x = px.floor();
15031 let anchor_y = py.floor();
15032 self.state.claim_mode = Some(ClaimModeState {
15033 zone_id: zone.id.clone(),
15034 width_m: side,
15035 height_m: side,
15036 anchor_x,
15037 anchor_y,
15038 });
15039 let label = zone
15040 .label
15041 .as_deref()
15042 .filter(|s| !s.trim().is_empty())
15043 .unwrap_or(zone.id.as_str());
15044 self.state.push_log(format!(
15045 "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
15046 ));
15047 }
15048
15049 pub fn cancel_claim_mode(&mut self) {
15050 if self.state.claim_mode.take().is_some() {
15051 self.state.push_log("Claim cancelled");
15052 }
15053 }
15054
15055 pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
15057 if !self.state.is_alive() {
15058 anyhow::bail!("you are dead");
15059 }
15060 if self.state.relocate_mode.is_some() {
15061 anyhow::bail!("already relocating — Enter confirm, Esc cancel");
15062 }
15063 if self.state.claim_mode.is_some() {
15064 anyhow::bail!("finish or cancel claim mode first");
15065 }
15066 let chest = self
15067 .state
15068 .placed_containers
15069 .iter()
15070 .find(|c| c.id == container_id)
15071 .cloned()
15072 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
15073 let (px, py) = self.state.player_position();
15074 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
15075 anyhow::bail!("too far from {}", chest.display_name);
15076 }
15077 if chest.locked && !chest.accessible {
15078 anyhow::bail!(
15079 "need the matching key for {} before moving it",
15080 chest.display_name
15081 );
15082 }
15083 let label = if chest.display_name.trim().is_empty() {
15084 chest.template_id.clone()
15085 } else {
15086 chest.display_name.clone()
15087 };
15088 self.state.relocate_mode = Some(RelocateModeState {
15089 container_id: chest.id.clone(),
15090 label: label.clone(),
15091 cursor_x: chest.x.floor() + 0.5,
15092 cursor_y: chest.y.floor() + 0.5,
15093 });
15094 self.state.push_log(format!(
15095 "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
15096 ));
15097 Ok(())
15098 }
15099
15100 pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
15102 let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
15103 anyhow::bail!("no chest nearby to relocate");
15104 };
15105 if chest.locked && !chest.accessible {
15106 anyhow::bail!(
15107 "need the matching key for {} before moving it",
15108 chest.display_name
15109 );
15110 }
15111 self.begin_relocate_container(&chest.id)
15114 }
15115
15116 pub fn cancel_relocate_mode(&mut self) {
15117 if self.state.relocate_mode.take().is_some() {
15118 self.state.push_log("Relocate cancelled");
15119 }
15120 }
15121
15122 pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
15123 let Some(mode) = self.state.relocate_mode.as_mut() else {
15124 return;
15125 };
15126 let max_x = self.state.world_width_m.max(1.0);
15127 let max_y = self.state.world_height_m.max(1.0);
15128 let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
15129 let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
15130 mode.cursor_x = nx.floor() + 0.5;
15131 mode.cursor_y = ny.floor() + 0.5;
15132 }
15133
15134 pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
15135 let Some(mode) = self.state.relocate_mode.as_mut() else {
15136 return;
15137 };
15138 let max_x = self.state.world_width_m.max(1.0);
15139 let max_y = self.state.world_height_m.max(1.0);
15140 mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
15141 mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
15142 }
15143
15144 pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
15145 if !self.state.is_alive() {
15146 anyhow::bail!("you are dead");
15147 }
15148 let Some(mode) = self.state.relocate_mode.clone() else {
15149 anyhow::bail!("not relocating");
15150 };
15151 let (px, py) = self.state.player_position();
15152 let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
15153 if dist > 8.0 {
15154 anyhow::bail!("destination too far (max 8 m)");
15155 }
15156 self.seq += 1;
15157 self.session
15158 .submit_intent(Intent::MovePlacedContainer {
15159 entity_id: self.state.entity_id,
15160 container_id: mode.container_id.clone(),
15161 x: mode.cursor_x,
15162 y: mode.cursor_y,
15163 seq: self.seq,
15164 })
15165 .await?;
15166 self.state.intents_sent += 1;
15167 self.state.relocate_mode = None;
15168 self.state.push_log(format!("Moving {}…", mode.label));
15169 Ok(())
15170 }
15171
15172 pub fn claim_set_preset(&mut self, w: u32, h: u32) {
15173 let Some(mode) = self.state.claim_mode.as_mut() else {
15174 return;
15175 };
15176 mode.width_m = w.max(1);
15177 mode.height_m = h.max(1);
15178 }
15179
15180 pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
15181 let Some(mode) = self.state.claim_mode.as_mut() else {
15182 return;
15183 };
15184 let w = (mode.width_m as i32 + dw).max(1) as u32;
15185 let h = (mode.height_m as i32 + dh).max(1) as u32;
15186 mode.width_m = w;
15187 mode.height_m = h;
15188 }
15189
15190 pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
15192 let Some(mode) = self.state.claim_mode.as_mut() else {
15193 return;
15194 };
15195 let max_x = self.state.world_width_m.max(1.0);
15196 let max_y = self.state.world_height_m.max(1.0);
15197 let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
15198 let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
15199 mode.anchor_x = nx.floor();
15200 mode.anchor_y = ny.floor();
15201 }
15202
15203 pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
15204 if !self.state.is_alive() {
15205 anyhow::bail!("you are dead");
15206 }
15207 let Some(mode) = self.state.claim_mode.clone() else {
15208 anyhow::bail!("not in claim mode");
15209 };
15210 let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
15211 self.state.claim_quote()
15212 else {
15213 anyhow::bail!("cannot quote claim");
15214 };
15215 if !valid {
15216 anyhow::bail!(reason);
15217 }
15218 if !can_afford {
15219 anyhow::bail!(
15220 "not enough copper (need {})",
15221 crate::currency::format_copper(purchase)
15222 );
15223 }
15224 let (x0, y0, x1, y1) = self
15225 .state
15226 .claim_footprint_rect()
15227 .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
15228 let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
15229 self.seq += 1;
15230 self.session
15231 .submit_intent(Intent::BuyPlot {
15232 entity_id: self.state.entity_id,
15233 zone_id: mode.zone_id,
15234 x0,
15235 y0,
15236 x1,
15237 y1,
15238 seq: self.seq,
15239 })
15240 .await?;
15241 self.state.intents_sent += 1;
15242 self.state.claim_mode = None;
15243 self.state.push_log(format!(
15244 "Buying plot for {}",
15245 crate::currency::format_copper(purchase)
15246 ));
15247 Ok(())
15248 }
15249
15250 pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
15251 if !self.state.is_alive() {
15252 anyhow::bail!("you are dead");
15253 }
15254 let zone_id = self
15255 .state
15256 .claim_mode
15257 .as_ref()
15258 .map(|m| m.zone_id.clone())
15259 .or_else(|| {
15260 self.state
15261 .free_property_zone_under_player()
15262 .map(|z| z.id.clone())
15263 })
15264 .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
15265 self.seq += 1;
15266 self.session
15267 .submit_intent(Intent::BuyPlotAllFree {
15268 entity_id: self.state.entity_id,
15269 zone_id,
15270 seq: self.seq,
15271 })
15272 .await?;
15273 self.state.intents_sent += 1;
15274 self.state.claim_mode = None;
15275 self.state.push_log("Claiming largest free plot…");
15276 Ok(())
15277 }
15278
15279 pub async fn confirm_sell_plot_to_crown(&mut self, plot_id: uuid::Uuid) -> anyhow::Result<()> {
15280 if !self.state.is_alive() {
15281 anyhow::bail!("you are dead");
15282 }
15283 self.seq += 1;
15284 self.session
15285 .submit_intent(Intent::SellPlotToCrown {
15286 entity_id: self.state.entity_id,
15287 plot_id,
15288 seq: self.seq,
15289 })
15290 .await?;
15291 self.state.intents_sent += 1;
15292 self.state.sell_plot_confirm = None;
15293 self.state.sell_plot_armed_at = None;
15294 self.state.push_log("Selling plot to the crown…");
15295 Ok(())
15296 }
15297
15298 pub async fn set_plot_farm_public(
15299 &mut self,
15300 plot_id: uuid::Uuid,
15301 public: bool,
15302 public_tax_discount_bps: u32,
15303 ) -> anyhow::Result<()> {
15304 self.seq += 1;
15305 self.session
15306 .submit_intent(Intent::SetPlotFarmPublic {
15307 entity_id: self.state.entity_id,
15308 plot_id,
15309 public,
15310 public_tax_discount_bps,
15311 seq: self.seq,
15312 })
15313 .await?;
15314 self.state.intents_sent += 1;
15315 Ok(())
15316 }
15317
15318 pub async fn plot_farm_allow_upsert(
15319 &mut self,
15320 plot_id: uuid::Uuid,
15321 character_id: Option<uuid::Uuid>,
15322 character_name: String,
15323 tax_discount_bps: u32,
15324 ) -> anyhow::Result<()> {
15325 self.seq += 1;
15326 self.session
15327 .submit_intent(Intent::PlotFarmAllowUpsert {
15328 entity_id: self.state.entity_id,
15329 plot_id,
15330 character_id,
15331 character_name,
15332 tax_discount_bps,
15333 seq: self.seq,
15334 })
15335 .await?;
15336 self.state.intents_sent += 1;
15337 Ok(())
15338 }
15339
15340 pub async fn plot_farm_allow_remove(
15341 &mut self,
15342 plot_id: uuid::Uuid,
15343 character_id: uuid::Uuid,
15344 ) -> anyhow::Result<()> {
15345 self.seq += 1;
15346 self.session
15347 .submit_intent(Intent::PlotFarmAllowRemove {
15348 entity_id: self.state.entity_id,
15349 plot_id,
15350 character_id,
15351 seq: self.seq,
15352 })
15353 .await?;
15354 self.state.intents_sent += 1;
15355 Ok(())
15356 }
15357
15358 pub fn open_farm_access_panel(&mut self) {
15359 let Some(plot) = self.state.my_plot_under_player() else {
15360 self.state
15361 .push_log("Stand on your deed plot to manage farm access");
15362 return;
15363 };
15364 self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
15365 self.state.farm_access_index = 0;
15366 self.state.show_farm_access = true;
15367 }
15368
15369 pub fn close_farm_access_panel(&mut self) {
15370 self.state.show_farm_access = false;
15371 self.state.farm_access_name_draft.clear();
15372 self.state.farm_access_index = 0;
15373 }
15374
15375 pub fn farm_access_move(&mut self, delta: i32) {
15376 let n = self.farm_access_row_count().max(1);
15377 let idx = self.state.farm_access_index as i32 + delta;
15378 self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
15379 }
15380
15381 pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
15382 let Some(plot) = self.state.my_plot_under_player() else {
15383 return vec![FarmAccessRow::PublicToggle];
15384 };
15385 let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
15386 for g in &plot.farm_allow {
15387 rows.push(FarmAccessRow::AllowRemove {
15388 character_id: g.character_id,
15389 label: if g.character_label.trim().is_empty() {
15390 g.character_id.to_string()[..8].to_string()
15391 } else {
15392 g.character_label.clone()
15393 },
15394 tax_discount_bps: g.tax_discount_bps,
15395 });
15396 }
15397 for e in &self.state.entities {
15398 if e.id == self.state.entity_id || e.label.trim().is_empty() {
15399 continue;
15400 }
15401 if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
15402 continue;
15403 }
15404 if self
15405 .state
15406 .npcs
15407 .iter()
15408 .any(|n| n.id == e.label || n.label == e.label)
15409 {
15410 continue;
15411 }
15412 if plot
15413 .farm_allow
15414 .iter()
15415 .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
15416 {
15417 continue;
15418 }
15419 rows.push(FarmAccessRow::NearbyAdd {
15420 name: e.label.clone(),
15421 });
15422 }
15423 rows
15424 }
15425
15426 pub fn farm_access_row_count(&self) -> usize {
15427 self.farm_access_rows().len().max(1)
15428 }
15429
15430 pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
15431 let Some(plot) = self.state.my_plot_under_player().cloned() else {
15432 self.close_farm_access_panel();
15433 return Ok(());
15434 };
15435 let rows = self.farm_access_rows();
15436 let Some(row) = rows.get(self.state.farm_access_index) else {
15437 return Ok(());
15438 };
15439 match row {
15440 FarmAccessRow::PublicToggle => {
15441 self.set_plot_farm_public(
15442 plot.plot_id,
15443 !plot.farm_public,
15444 plot.public_tax_discount_bps,
15445 )
15446 .await
15447 }
15448 FarmAccessRow::PublicDiscount => Ok(()),
15449 FarmAccessRow::AllowRemove { character_id, .. } => {
15450 self.plot_farm_allow_remove(plot.plot_id, *character_id)
15451 .await
15452 }
15453 FarmAccessRow::NearbyAdd { name } => {
15454 let disc = self
15455 .state
15456 .farm_access_discount_bps
15457 .max(plot.public_tax_discount_bps);
15458 self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
15459 .await
15460 }
15461 }
15462 }
15463
15464 pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
15465 let Some(plot) = self.state.my_plot_under_player().cloned() else {
15466 return Ok(());
15467 };
15468 let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
15469 self.state.farm_access_discount_bps = next;
15470 self.state.farm_access_index = 1;
15471 self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
15472 .await
15473 }
15474
15475 pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
15477 if self.state.farmable_plot_under_player().is_none() {
15478 anyhow::bail!("stand on a farmable plot to cultivate");
15479 }
15480 let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
15481 let (px, py) = self.state.player_position();
15482 if self
15483 .state
15484 .terrain_at(px, py)
15485 .is_some_and(|k| k == TerrainKindView::Tilled)
15486 {
15487 anyhow::bail!("already tilled — stand on bare soil and press c");
15488 }
15489 anyhow::bail!("cannot till this cell — move onto soil on your plot");
15490 };
15491 self.cultivate_at(tx, ty).await
15492 }
15493
15494 pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
15496 if self.state.farmable_plot_under_player().is_none() {
15497 anyhow::bail!("stand on a farmable plot to plant");
15498 }
15499 if !self.state.underfoot_free_tilled_plant_slot() {
15500 anyhow::bail!("stand on empty tilled soil and press p");
15501 }
15502 let seeds = self.state.farm_seed_entries();
15503 if seeds.is_empty() {
15504 anyhow::bail!("no seeds in inventory — buy seeds from Eli");
15505 }
15506 if seeds.len() == 1 {
15507 return self.plant_seeds(seeds[0].0.clone(), 1).await;
15508 }
15509 self.open_plant_menu();
15510 Ok(())
15511 }
15512
15513 pub fn open_plot_build_menu(&mut self) -> anyhow::Result<()> {
15515 let Some(plot) = self.state.my_plot_under_player() else {
15516 anyhow::bail!("stand on your plot to build");
15517 };
15518 if plot.building_id.is_some() {
15519 anyhow::bail!("this plot already has a building");
15520 }
15521 let building_now = self
15522 .state
15523 .timed_channel
15524 .as_ref()
15525 .is_some_and(|c| c.channel == flatland_protocol::TimedChannelKind::Build);
15526 if !building_now && self.state.building_materials.is_empty() {
15527 anyhow::bail!("no building materials loaded — wait a moment and try again");
15528 }
15529 self.state.show_plot_build_menu = true;
15530 self.state.show_craft_menu = false;
15531 self.state.show_shop_menu = false;
15532 self.state.shop_catalog = None;
15533 self.state.show_stats = false;
15534 self.state.show_inventory_menu = false;
15535 self.state.plot_build_focus_wall = true;
15536 let walls = self.state.plot_build_wall_options().len();
15537 let roofs = self.state.plot_build_roof_options().len();
15538 if walls > 0 {
15539 self.state.plot_build_wall_index = self.state.plot_build_wall_index.min(walls - 1);
15540 } else {
15541 self.state.plot_build_wall_index = 0;
15542 }
15543 if roofs > 0 {
15544 self.state.plot_build_roof_index = self.state.plot_build_roof_index.min(roofs - 1);
15545 } else {
15546 self.state.plot_build_roof_index = 0;
15547 }
15548 Ok(())
15549 }
15550
15551 pub fn close_plot_build_menu(&mut self) {
15552 self.state.show_plot_build_menu = false;
15553 }
15554
15555 pub fn plot_build_menu_move(&mut self, delta: i32) {
15556 let walls = self.state.plot_build_wall_options();
15557 let roofs = self.state.plot_build_roof_options();
15558 if self.state.plot_build_focus_wall {
15559 if walls.is_empty() {
15560 return;
15561 }
15562 let n = walls.len() as i32;
15563 let cur = self.state.plot_build_wall_index as i32;
15564 self.state.plot_build_wall_index = ((cur + delta).rem_euclid(n)) as usize;
15565 } else {
15566 if roofs.is_empty() {
15567 return;
15568 }
15569 let n = roofs.len() as i32;
15570 let cur = self.state.plot_build_roof_index as i32;
15571 self.state.plot_build_roof_index = ((cur + delta).rem_euclid(n)) as usize;
15572 }
15573 }
15574
15575 pub fn plot_build_menu_toggle_focus(&mut self) {
15576 self.state.plot_build_focus_wall = !self.state.plot_build_focus_wall;
15577 }
15578
15579 pub async fn plot_build_menu_confirm(&mut self) -> anyhow::Result<()> {
15581 let wall = self
15582 .state
15583 .plot_build_selected_wall()
15584 .ok_or_else(|| anyhow::anyhow!("pick a wall material"))?
15585 .id
15586 .clone();
15587 let roof = self
15588 .state
15589 .plot_build_selected_roof()
15590 .ok_or_else(|| anyhow::anyhow!("pick a roof material"))?
15591 .id
15592 .clone();
15593 self.start_plot_build(&wall, &roof).await
15595 }
15596
15597 pub async fn plot_build_menu_cancel_build(&mut self) -> anyhow::Result<()> {
15599 self.seq += 1;
15600 self.session
15601 .submit_intent(Intent::CancelPlotBuild {
15602 entity_id: self.state.entity_id,
15603 seq: self.seq,
15604 })
15605 .await?;
15606 self.state.intents_sent += 1;
15607 Ok(())
15608 }
15609
15610 pub async fn start_plot_build(
15612 &mut self,
15613 wall_material_id: &str,
15614 roof_material_id: &str,
15615 ) -> anyhow::Result<()> {
15616 let Some(plot) = self.state.my_plot_under_player() else {
15617 anyhow::bail!("stand on your plot to build");
15618 };
15619 if plot.building_id.is_some() {
15620 anyhow::bail!("this plot already has a building");
15621 }
15622 let plot_id = plot.plot_id;
15623 self.seq += 1;
15624 self.session
15625 .submit_intent(Intent::StartPlotBuild {
15626 entity_id: self.state.entity_id,
15627 plot_id,
15628 wall_material_id: wall_material_id.to_string(),
15629 roof_material_id: roof_material_id.to_string(),
15630 seq: self.seq,
15631 })
15632 .await?;
15633 self.state.intents_sent += 1;
15634 Ok(())
15635 }
15636
15637 pub async fn toggle_nearby_door_lock(&mut self) -> anyhow::Result<()> {
15639 let (px, py) = self.state.player_position();
15640 let mut best: Option<(f32, String, bool)> = None;
15641 for d in &self.state.doors {
15642 if d.lock_id.is_none() {
15643 continue;
15644 }
15645 let dist = (d.x - px).hypot(d.y - py);
15646 if dist > DOOR_INTERACTION_RADIUS_M {
15647 continue;
15648 }
15649 if best.as_ref().is_none_or(|(bd, _, _)| dist < *bd) {
15650 best = Some((dist, d.id.clone(), d.locked));
15651 }
15652 }
15653 let Some((_, door_id, locked_now)) = best else {
15654 anyhow::bail!("no lockable door nearby");
15655 };
15656 let locked = !locked_now;
15657 self.seq += 1;
15658 self.session
15659 .submit_intent(Intent::SetDoorLocked {
15660 entity_id: self.state.entity_id,
15661 door_id,
15662 locked,
15663 seq: self.seq,
15664 })
15665 .await?;
15666 self.state.intents_sent += 1;
15667 Ok(())
15668 }
15669
15670 pub async fn enter_nearby_open_door(&mut self) -> anyhow::Result<()> {
15672 if !self.state.is_alive() {
15673 anyhow::bail!("you are dead");
15674 }
15675 if self.state.effective_inside_building().is_some() {
15676 anyhow::bail!("already inside");
15677 }
15678 let (px, py) = self.state.player_position();
15679 let mut best: Option<(f32, String)> = None;
15680 for d in &self.state.doors {
15681 if !d.open || d.locked {
15682 continue;
15683 }
15684 let player_house = self
15685 .state
15686 .buildings
15687 .iter()
15688 .find(|b| b.id == d.building_id)
15689 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
15690 if !player_house {
15691 continue;
15692 }
15693 let dist = (d.x - px).hypot(d.y - py);
15694 if dist > DOOR_INTERACTION_RADIUS_M {
15695 continue;
15696 }
15697 if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
15698 best = Some((dist, d.id.clone()));
15699 }
15700 }
15701 let Some((_, door_id)) = best else {
15702 anyhow::bail!("no open house door nearby — open with f first");
15703 };
15704 self.seq += 1;
15705 self.session
15706 .submit_intent(Intent::EnterBuildingDoor {
15707 entity_id: self.state.entity_id,
15708 door_id,
15709 seq: self.seq,
15710 })
15711 .await?;
15712 self.state.intents_sent += 1;
15713 Ok(())
15714 }
15715
15716 pub async fn exit_nearby_building_door(&mut self) -> anyhow::Result<()> {
15719 if !self.state.is_alive() {
15720 anyhow::bail!("you are dead");
15721 }
15722 let Some(bid) = self.state.effective_inside_building() else {
15723 anyhow::bail!("not inside a building");
15724 };
15725 let (px, py) = self.state.player_position();
15726 let mut best: Option<(f32, String)> = None;
15727 for d in &self.state.doors {
15728 if d.building_id != bid || d.portal.is_none() {
15729 continue;
15730 }
15731 let player_house = self
15732 .state
15733 .buildings
15734 .iter()
15735 .find(|b| b.id == d.building_id)
15736 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
15737 if !player_house {
15738 continue;
15739 }
15740 let dist = (d.x - px).hypot(d.y - py);
15741 if dist > 1.5 {
15742 continue;
15743 }
15744 if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
15745 best = Some((dist, d.id.clone()));
15746 }
15747 }
15748 let Some((_, door_id)) = best else {
15749 anyhow::bail!("stand by the door to exit");
15750 };
15751 self.seq += 1;
15752 self.session
15753 .submit_intent(Intent::ExitBuildingDoor {
15754 entity_id: self.state.entity_id,
15755 door_id,
15756 seq: self.seq,
15757 })
15758 .await?;
15759 self.state.intents_sent += 1;
15760 Ok(())
15761 }
15762
15763 pub async fn confirm_interior_edit(
15765 &mut self,
15766 building_id: String,
15767 rooms: Vec<flatland_protocol::InteriorRoomEdit>,
15768 room_doors: Vec<flatland_protocol::InteriorRoomDoorEdit>,
15769 ) -> anyhow::Result<()> {
15770 self.seq += 1;
15771 self.session
15772 .submit_intent(Intent::ConfirmInteriorEdit {
15773 entity_id: self.state.entity_id,
15774 building_id,
15775 rooms,
15776 room_doors,
15777 seq: self.seq,
15778 })
15779 .await?;
15780 self.state.intents_sent += 1;
15781 Ok(())
15782 }
15783
15784 pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
15785 if !self.state.is_alive() {
15786 anyhow::bail!("you are dead");
15787 }
15788 self.seq += 1;
15789 self.session
15790 .submit_intent(Intent::Cultivate {
15791 entity_id: self.state.entity_id,
15792 x,
15793 y,
15794 seq: self.seq,
15795 })
15796 .await?;
15797 self.state.intents_sent += 1;
15798 Ok(())
15799 }
15800
15801 pub async fn plant_seeds(
15802 &mut self,
15803 seed_template_id: String,
15804 quantity: u32,
15805 ) -> anyhow::Result<()> {
15806 if !self.state.is_alive() {
15807 anyhow::bail!("you are dead");
15808 }
15809 self.seq += 1;
15810 self.session
15811 .submit_intent(Intent::PlantSeeds {
15812 entity_id: self.state.entity_id,
15813 seed_template_id: seed_template_id.clone(),
15814 quantity,
15815 seq: self.seq,
15816 })
15817 .await?;
15818 self.state.intents_sent += 1;
15819 self.state
15820 .push_log(format!("Planting {quantity}× {seed_template_id}…"));
15821 Ok(())
15822 }
15823
15824 pub fn open_plant_menu(&mut self) {
15825 if self.state.farm_seed_entries().is_empty() {
15826 self.state.push_log("No seeds in inventory to plant");
15827 return;
15828 }
15829 self.state.show_plant_menu = true;
15830 self.state.plant_menu_index = 0;
15831 self.state.plant_quantity = 1;
15832 self.state.clamp_plant_menu();
15833 }
15834
15835 pub fn close_plant_menu(&mut self) {
15836 self.state.show_plant_menu = false;
15837 }
15838
15839 pub fn plant_menu_move(&mut self, delta: i32) {
15840 let n = self.state.farm_seed_entries().len();
15841 if n == 0 {
15842 return;
15843 }
15844 let idx = self.state.plant_menu_index as i32 + delta;
15845 self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
15846 self.state.clamp_plant_menu();
15847 }
15848
15849 pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
15850 let next = self.state.plant_quantity as i32 + delta;
15851 self.state.plant_quantity = next.max(1) as u32;
15852 self.state.clamp_plant_menu();
15853 }
15854
15855 pub fn plant_menu_set_quantity_max(&mut self) {
15856 if let Some((_, max, _)) = self.state.plant_menu_selection() {
15857 self.state.plant_quantity = max;
15858 }
15859 self.state.clamp_plant_menu();
15860 }
15861
15862 pub fn plant_menu_set_quantity_min(&mut self) {
15863 self.state.plant_quantity = 1;
15864 self.state.clamp_plant_menu();
15865 }
15866
15867 pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
15868 let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
15869 self.close_plant_menu();
15870 anyhow::bail!("no seeds to plant");
15871 };
15872 self.close_plant_menu();
15873 self.plant_seeds(seed, qty).await?;
15874 self.state.push_log(format!("Planted {qty}× {label}"));
15875 Ok(())
15876 }
15877
15878 pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
15881 if !self.state.is_alive() {
15882 anyhow::bail!("you are dead");
15883 }
15884 let binding = self
15885 .state
15886 .hotbar_ability(slot)
15887 .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
15888 .to_string();
15889 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
15890 let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
15891 if qty == 0 {
15892 anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
15893 }
15894 return self.use_item(template_id).await;
15895 }
15896 let ability_id = binding;
15897 if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
15898 return self
15899 .cast_ability(&ability_id, Some(self.state.entity_id))
15900 .await;
15901 }
15902 let is_heal = ability_id == "heal_touch"
15903 || self
15904 .state
15905 .ability_meta
15906 .get(&ability_id)
15907 .map(|meta| meta.is_heal)
15908 .unwrap_or(false);
15909 let target = if is_heal {
15910 Some(
15911 self.state
15912 .target_for_slot(2)
15913 .unwrap_or(self.state.entity_id),
15914 )
15915 } else {
15916 self.state
15917 .target_for_slot(1)
15918 .or_else(|| self.state.target_for_slot(2))
15919 };
15920 let Some(target_id) = target else {
15921 anyhow::bail!("no target — Tab to select, then press the hotbar key");
15922 };
15923 self.cast_ability(&ability_id, Some(target_id)).await
15924 }
15925
15926 pub async fn set_hotbar_slot(
15929 &mut self,
15930 slot: u8,
15931 ability_id: Option<&str>,
15932 ) -> anyhow::Result<()> {
15933 if !self.state.is_alive() {
15934 anyhow::bail!("you are dead");
15935 }
15936 if !(1..=9).contains(&slot) {
15937 anyhow::bail!("hotbar slot must be 1–9");
15938 }
15939 let ability_id = ability_id
15940 .map(str::trim)
15941 .filter(|id| !id.is_empty())
15942 .map(str::to_string);
15943 self.seq += 1;
15944 self.session
15945 .submit_intent(Intent::SetHotbarSlot {
15946 entity_id: self.state.entity_id,
15947 slot,
15948 ability_id: ability_id.clone(),
15949 seq: self.seq,
15950 })
15951 .await?;
15952 self.state.intents_sent += 1;
15953 let idx = (slot - 1) as usize;
15954 if self.state.hotbar.len() < 9 {
15955 self.state.hotbar.resize(9, None);
15956 }
15957 if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
15958 *slot_mut = ability_id.clone();
15959 }
15960 match ability_id {
15961 Some(id) => {
15962 let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
15963 format!("use {tid}")
15964 } else {
15965 id
15966 };
15967 self.state.push_log(format!("Hotbar {slot} → {label}"))
15968 }
15969 None => self.state.push_log(format!("Hotbar {slot} cleared")),
15970 }
15971 Ok(())
15972 }
15973
15974 pub fn npc_verb_options(&self) -> Vec<NpcVerbChoice> {
15975 self.state.npc_verb_options()
15976 }
15977
15978 pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
15979 let Some(npc_id) = self.state.npc_verb_target.clone() else {
15980 return Ok(());
15981 };
15982 let options = self.npc_verb_options();
15983 let choice = options
15984 .get(self.state.npc_verb_index)
15985 .cloned()
15986 .unwrap_or_else(GameState::talk_choice);
15987 match choice.action {
15988 NpcVerbAction::QuestGive { quest_id } => {
15989 self.submit_npc_quest_turn_in(&npc_id, Some(&quest_id))
15990 .await?;
15991 self.state.show_npc_verb_menu = false;
15992 }
15993 NpcVerbAction::Talk => {
15994 self.open_npc_talk(&npc_id, None).await?;
15995 }
15996 NpcVerbAction::QuestTalk { quest_id } => {
15997 self.open_npc_talk(&npc_id, Some(&quest_id)).await?;
15998 }
15999 NpcVerbAction::Trade
16000 | NpcVerbAction::Bank
16001 | NpcVerbAction::Storage
16002 | NpcVerbAction::Market => {
16003 self.seq += 1;
16004 self.session
16005 .submit_intent(Intent::Interact {
16006 entity_id: self.state.entity_id,
16007 target_id: npc_id,
16008 seq: self.seq,
16009 })
16010 .await?;
16011 self.state.intents_sent += 1;
16012 }
16013 }
16014 Ok(())
16015 }
16016
16017 async fn open_npc_talk(&mut self, npc_id: &str, quest_id: Option<&str>) -> anyhow::Result<()> {
16018 self.seq += 1;
16019 self.session
16020 .submit_intent(Intent::NpcTalkOpen {
16021 entity_id: self.state.entity_id,
16022 npc_id: npc_id.to_string(),
16023 quest_id: quest_id.map(str::to_string),
16024 seq: self.seq,
16025 })
16026 .await?;
16027 self.state.intents_sent += 1;
16028 Ok(())
16029 }
16030
16031 async fn submit_npc_quest_turn_in(
16032 &mut self,
16033 npc_id: &str,
16034 quest_id: Option<&str>,
16035 ) -> anyhow::Result<()> {
16036 let catalog_ref = self.state.npc_quest_catalog_ref(npc_id);
16037 let pending: Vec<(String, u32, String)> = self
16038 .state
16039 .quest_log
16040 .iter()
16041 .filter(|q| {
16042 q.status == flatland_protocol::QuestStatusView::Active
16043 && quest_id.is_none_or(|id| q.quest_id == id)
16044 })
16045 .flat_map(|q| q.objectives.iter())
16046 .filter(|o| {
16047 !o.done
16048 && o.kind == "give_item"
16049 && o.npc_ref.as_deref() == Some(catalog_ref.as_str())
16050 })
16051 .filter_map(|o| {
16052 let template = o.item_template.clone()?;
16053 let remaining = o.required.saturating_sub(o.current);
16054 if remaining == 0 {
16055 return None;
16056 }
16057 Some((template, remaining, o.label.clone()))
16058 })
16059 .collect();
16060 if pending.is_empty() {
16061 self.state.push_log("Nothing to turn in here.");
16062 return Ok(());
16063 }
16064 let mut sent = 0u32;
16065 for (template, remaining, label) in pending {
16066 let held = self.state.count_inventory_template(&template);
16067 let qty = remaining.min(held);
16068 if qty == 0 {
16069 self.state.push_log(format!("Need {label}"));
16070 continue;
16071 }
16072 self.seq += 1;
16073 self.session
16074 .submit_intent(Intent::QuestGiveItem {
16075 entity_id: self.state.entity_id,
16076 npc_id: npc_id.to_string(),
16077 template_id: template,
16078 quantity: qty,
16079 seq: self.seq,
16080 })
16081 .await?;
16082 self.state.intents_sent += 1;
16083 sent += 1;
16084 }
16085 if sent > 0 {
16086 self.state.push_log("Turning in quest items.");
16087 }
16088 Ok(())
16089 }
16090
16091 pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
16092 let Some(chat) = self.state.npc_chat.clone() else {
16093 return Ok(());
16094 };
16095 let message = chat.input.trim().to_string();
16096 if message.is_empty() || chat.pending {
16097 return Ok(());
16098 }
16099 if let Some(c) = self.state.npc_chat.as_mut() {
16100 c.lines.push(format!("You: {message}"));
16101 c.input.clear();
16102 c.pending = true;
16103 }
16104 self.seq += 1;
16105 self.session
16106 .submit_intent(Intent::NpcTalkSay {
16107 entity_id: self.state.entity_id,
16108 npc_id: chat.npc_id,
16109 message,
16110 seq: self.seq,
16111 })
16112 .await?;
16113 self.state.intents_sent += 1;
16114 Ok(())
16115 }
16116
16117 pub async fn npc_talk_topic(&mut self, index: usize) -> anyhow::Result<()> {
16118 let topic = self
16119 .state
16120 .npc_chat
16121 .as_ref()
16122 .and_then(|c| c.suggested_topics.get(index))
16123 .cloned();
16124 let Some(topic) = topic else {
16125 return Ok(());
16126 };
16127 if let Some(c) = self.state.npc_chat.as_mut() {
16128 if c.pending {
16129 return Ok(());
16130 }
16131 c.input = topic;
16132 }
16133 self.npc_talk_send().await
16134 }
16135
16136 pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
16137 let return_to_verbs = self.state.npc_verb_target.is_some();
16138 let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
16139 self.state.show_npc_chat = false;
16140 if return_to_verbs {
16141 self.state.show_npc_verb_menu = true;
16142 }
16143 return Ok(());
16144 };
16145 self.seq += 1;
16146 self.session
16147 .submit_intent(Intent::NpcTalkClose {
16148 entity_id: self.state.entity_id,
16149 npc_id,
16150 seq: self.seq,
16151 })
16152 .await?;
16153 self.state.intents_sent += 1;
16154 self.state.show_npc_chat = false;
16155 self.state.npc_chat = None;
16156 if return_to_verbs {
16157 self.state.show_npc_verb_menu = true;
16158 self.state.npc_verb_notice = None;
16159 }
16160 Ok(())
16161 }
16162
16163 pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
16165 if self.state.show_quest_offer
16166 && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
16167 {
16168 self.quest_offer_decline();
16169 return Ok(());
16170 }
16171 if self.state.show_npc_chat {
16172 return self.npc_talk_close().await;
16173 }
16174 if self.state.show_shop_menu {
16175 return self.back_from_shop_menu().await;
16176 }
16177 if self.state.bank_panel.is_some() {
16178 if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
16179 self.bank_transfer_back();
16180 return Ok(());
16181 }
16182 return self.close_bank_panel().await;
16183 }
16184 if self.state.storage_panel.is_some() {
16185 if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
16186 self.storage_ui_back();
16187 return Ok(());
16188 }
16189 return self.close_storage_panel().await;
16190 }
16191 if self.state.market_panel.is_some() {
16192 if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
16193 self.market_ui_back();
16194 return Ok(());
16195 }
16196 if self.state.market_buy_confirm.is_some() {
16197 self.state.market_buy_confirm = None;
16198 return Ok(());
16199 }
16200 return self.close_market_panel().await;
16201 }
16202 if self.state.show_npc_verb_menu {
16203 self.state.show_npc_verb_menu = false;
16204 self.state.npc_verb_target = None;
16205 }
16206 Ok(())
16207 }
16208
16209 pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
16210 self.seq += 1;
16211 self.session
16212 .submit_intent(Intent::TestDamage {
16213 entity_id: self.state.entity_id,
16214 amount,
16215 seq: self.seq,
16216 })
16217 .await?;
16218 self.state.intents_sent += 1;
16219 Ok(())
16220 }
16221
16222 pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
16223 self.cycle_combat_target_slot(1, reverse).await
16224 }
16225
16226 pub async fn cycle_combat_target_slot(
16227 &mut self,
16228 slot_index: u8,
16229 reverse: bool,
16230 ) -> anyhow::Result<()> {
16231 if !self.state.is_alive() {
16232 anyhow::bail!("you are dead");
16233 }
16234 let candidates = self.state.candidates_for_slot(slot_index);
16235 if candidates.is_empty() {
16236 anyhow::bail!("no targets nearby");
16237 }
16238 let current = self.state.target_for_slot(slot_index);
16239 let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
16240 let next_idx = match idx {
16241 None => 0,
16242 Some(i) if reverse => {
16243 if i == 0 {
16244 candidates.len() - 1
16245 } else {
16246 i - 1
16247 }
16248 }
16249 Some(i) => (i + 1) % candidates.len(),
16250 };
16251 if idx == Some(next_idx) && candidates.len() == 1 {
16252 self.clear_combat_target_slot(slot_index).await?;
16253 return Ok(());
16254 }
16255 let (target_id, label) = candidates[next_idx].clone();
16256 self.set_combat_target_slot(slot_index, target_id, &label)
16257 .await
16258 }
16259
16260 pub async fn set_combat_target_slot(
16261 &mut self,
16262 slot_index: u8,
16263 target_id: EntityId,
16264 label: &str,
16265 ) -> anyhow::Result<()> {
16266 if !self.state.is_alive() {
16267 anyhow::bail!("you are dead");
16268 }
16269 self.seq += 1;
16270 self.session
16271 .submit_intent(Intent::SetTargetSlot {
16272 entity_id: self.state.entity_id,
16273 slot_index,
16274 target_id,
16275 seq: self.seq,
16276 })
16277 .await?;
16278 self.state.intents_sent += 1;
16279 if slot_index == 1 {
16280 self.state.combat_target = Some(target_id);
16281 self.state.combat_target_label = Some(label.to_string());
16282 }
16283 self.state
16284 .push_log(format!("Slot {slot_index} target: {label}"));
16285 Ok(())
16286 }
16287
16288 pub async fn set_combat_target(
16289 &mut self,
16290 target_id: EntityId,
16291 label: &str,
16292 ) -> anyhow::Result<()> {
16293 self.set_combat_target_slot(1, target_id, label).await
16294 }
16295
16296 pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
16297 if slot_index == 1 && self.state.combat_target.is_none() {
16298 return Ok(());
16299 }
16300 self.seq += 1;
16301 self.session
16302 .submit_intent(Intent::ClearTargetSlot {
16303 entity_id: self.state.entity_id,
16304 slot_index,
16305 seq: self.seq,
16306 })
16307 .await?;
16308 if slot_index == 1 {
16309 self.state.combat_target = None;
16310 self.state.combat_target_label = None;
16311 }
16312 self.state.intents_sent += 1;
16313 self.state
16314 .push_log(format!("Slot {slot_index} target cleared"));
16315 Ok(())
16316 }
16317
16318 pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
16319 self.clear_combat_target_slot(1).await
16320 }
16321
16322 pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
16323 if !self.state.is_alive() {
16324 anyhow::bail!("you are dead");
16325 }
16326 self.seq += 1;
16327 self.session
16328 .submit_intent(Intent::AdvanceRotation {
16329 entity_id: self.state.entity_id,
16330 slot_index,
16331 seq: self.seq,
16332 })
16333 .await?;
16334 self.state.intents_sent += 1;
16335 Ok(())
16336 }
16337
16338 pub async fn assign_slot_preset(
16339 &mut self,
16340 slot_index: u8,
16341 preset_id: &str,
16342 ) -> anyhow::Result<()> {
16343 if !self.state.is_alive() {
16344 anyhow::bail!("you are dead");
16345 }
16346 self.seq += 1;
16347 self.session
16348 .submit_intent(Intent::AssignSlotPreset {
16349 entity_id: self.state.entity_id,
16350 slot_index,
16351 preset_id: preset_id.to_string(),
16352 seq: self.seq,
16353 })
16354 .await?;
16355 self.state.intents_sent += 1;
16356 if let Some(slot) = self
16357 .state
16358 .combat_slots
16359 .iter_mut()
16360 .find(|s| s.slot_index == slot_index)
16361 {
16362 slot.preset_id = Some(preset_id.to_string());
16363 if let Some(preset) = self
16364 .state
16365 .rotation_presets
16366 .iter()
16367 .find(|p| p.id == preset_id)
16368 {
16369 slot.preset_label = Some(preset.label.clone());
16370 slot.rotation = preset.abilities.clone();
16371 slot.rotation_index = 0;
16372 }
16373 }
16374 self.state
16375 .push_log(format!("T{slot_index} loadout → {preset_id}"));
16376 Ok(())
16377 }
16378
16379 pub async fn cast_ability(
16380 &mut self,
16381 ability_id: &str,
16382 target_id: Option<EntityId>,
16383 ) -> anyhow::Result<()> {
16384 if !self.state.is_alive() {
16385 anyhow::bail!("you are dead");
16386 }
16387 let allows_ground = self.state.ability_allows_ground(ability_id);
16388 let requires_ground = self.state.ability_requires_ground(ability_id);
16389 if requires_ground && self.state.ground_target.is_none() {
16390 anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
16391 }
16392 let (resolved_target_id, target_point) = if allows_ground {
16393 if let Some((x, y, z)) = self.state.ground_target {
16394 (
16395 target_id.unwrap_or(self.state.entity_id),
16396 Some(flatland_protocol::AimPoint { x, y, z }),
16397 )
16398 } else {
16399 (
16400 target_id
16401 .or_else(|| self.state.target_for_slot(2))
16402 .or_else(|| self.state.target_for_slot(1))
16403 .unwrap_or(self.state.entity_id),
16404 None,
16405 )
16406 }
16407 } else {
16408 (
16409 target_id
16410 .or_else(|| self.state.target_for_slot(2))
16411 .or_else(|| self.state.target_for_slot(1))
16412 .unwrap_or(self.state.entity_id),
16413 None,
16414 )
16415 };
16416 self.seq += 1;
16417 self.session
16418 .submit_intent(Intent::Cast {
16419 entity_id: self.state.entity_id,
16420 ability_id: ability_id.to_string(),
16421 target_id: resolved_target_id,
16422 target_point,
16423 seq: self.seq,
16424 })
16425 .await?;
16426 self.state.intents_sent += 1;
16427 match target_point {
16428 Some(point) => self.state.push_log(format!(
16429 "Cast {ability_id} → ({:.1}, {:.1})",
16430 point.x, point.y
16431 )),
16432 None => self
16433 .state
16434 .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
16435 }
16436 Ok(())
16437 }
16438
16439 pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
16440 self.seq += 1;
16441 self.session
16442 .submit_intent(Intent::UpsertRotationPreset {
16443 entity_id: self.state.entity_id,
16444 preset: preset.clone(),
16445 seq: self.seq,
16446 })
16447 .await?;
16448 self.state.intents_sent += 1;
16449 if let Some(existing) = self
16450 .state
16451 .rotation_presets
16452 .iter_mut()
16453 .find(|p| p.id == preset.id)
16454 {
16455 *existing = preset.clone();
16456 } else {
16457 self.state.rotation_presets.push(preset.clone());
16458 }
16459 for slot in &mut self.state.combat_slots {
16460 if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
16461 slot.preset_label = Some(preset.label.clone());
16462 slot.rotation = preset.abilities.clone();
16463 }
16464 }
16465 self.state
16466 .push_log(format!("Saved rotation: {}", preset.label));
16467 Ok(())
16468 }
16469
16470 pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
16471 self.seq += 1;
16472 self.session
16473 .submit_intent(Intent::DeleteRotationPreset {
16474 entity_id: self.state.entity_id,
16475 preset_id: preset_id.to_string(),
16476 seq: self.seq,
16477 })
16478 .await?;
16479 self.state.intents_sent += 1;
16480 self.state.rotation_presets.retain(|p| p.id != preset_id);
16481 for slot in &mut self.state.combat_slots {
16482 if slot.preset_id.as_deref() == Some(preset_id) {
16483 slot.preset_id = None;
16484 slot.preset_label = None;
16485 slot.rotation.clear();
16486 slot.rotation_index = 0;
16487 }
16488 }
16489 self.state
16490 .push_log(format!("Deleted rotation: {preset_id}"));
16491 Ok(())
16492 }
16493
16494 pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
16495 if !self.state.is_alive() {
16496 anyhow::bail!("you are dead");
16497 }
16498 let enabled = !self
16499 .state
16500 .combat_slots
16501 .iter()
16502 .find(|s| s.slot_index == slot_index)
16503 .map(|s| s.auto_enabled)
16504 .unwrap_or(false);
16505 self.seq += 1;
16506 self.session
16507 .submit_intent(Intent::SetAutoAttack {
16508 entity_id: self.state.entity_id,
16509 slot_index,
16510 enabled,
16511 seq: self.seq,
16512 })
16513 .await?;
16514 if slot_index == 1 {
16515 self.state.auto_attack = enabled;
16516 }
16517 self.state.intents_sent += 1;
16518 self.state.push_log(format!(
16519 "T{slot_index} auto {}",
16520 if enabled { "ON" } else { "OFF" }
16521 ));
16522 Ok(())
16523 }
16524
16525 pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
16526 if !self.state.connected {
16527 anyhow::bail!("not connected");
16528 }
16529 if !self.state.is_alive() {
16530 anyhow::bail!("you are dead");
16531 }
16532 let (px, py) = self.state.player_position();
16533 if self
16534 .state
16535 .ground_drops
16536 .iter()
16537 .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
16538 {
16539 anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
16540 }
16541 self.seq += 1;
16542 self.session
16543 .submit_intent(Intent::Pickup {
16544 entity_id: self.state.entity_id,
16545 drop_id: None,
16546 seq: self.seq,
16547 })
16548 .await?;
16549 self.state.intents_sent += 1;
16550 self.state.push_audio(crate::social::AudioCue::LootPickup);
16551 Ok(())
16552 }
16553
16554 pub async fn dodge(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
16555 if !self.state.is_alive() {
16556 anyhow::bail!("you are dead");
16557 }
16558 self.seq += 1;
16560 self.session
16561 .submit_intent(Intent::Dodge {
16562 entity_id: self.state.entity_id,
16563 forward,
16564 strafe,
16565 seq: self.seq,
16566 })
16567 .await?;
16568 self.state.intents_sent += 1;
16569 self.state.push_log("Dodge!");
16570 self.state.push_audio(crate::social::AudioCue::CombatDodge);
16571 Ok(())
16572 }
16573
16574 pub async fn lunge(&mut self) -> anyhow::Result<()> {
16575 if !self.state.is_alive() {
16576 anyhow::bail!("you are dead");
16577 }
16578 let (forward, strafe) = self.last_move_axes();
16579 self.seq += 1;
16580 self.session
16581 .submit_intent(Intent::Lunge {
16582 entity_id: self.state.entity_id,
16583 forward,
16584 strafe,
16585 seq: self.seq,
16586 })
16587 .await?;
16588 self.state.intents_sent += 1;
16589 self.state.push_log("Lunge!");
16590 Ok(())
16591 }
16592
16593 pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
16594 if !self.state.is_alive() {
16595 anyhow::bail!("you are dead");
16596 }
16597 self.seq += 1;
16598 self.session
16599 .submit_intent(Intent::DirectionalJump {
16600 entity_id: self.state.entity_id,
16601 forward,
16602 strafe,
16603 seq: self.seq,
16604 })
16605 .await?;
16606 self.state.intents_sent += 1;
16607 self.state.push_log("Jump!");
16608 Ok(())
16609 }
16610
16611 pub fn last_move_axes(&self) -> (f32, f32) {
16613 (self.last_move_forward, self.last_move_strafe)
16614 }
16615
16616 pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
16617 if !self.state.is_alive() {
16618 anyhow::bail!("you are dead");
16619 }
16620 self.seq += 1;
16621 self.session
16622 .submit_intent(Intent::Block {
16623 entity_id: self.state.entity_id,
16624 enabled,
16625 seq: self.seq,
16626 })
16627 .await?;
16628 self.state.intents_sent += 1;
16629 if enabled {
16630 self.state.push_log("Blocking");
16631 self.state.push_audio(crate::social::AudioCue::CombatBlock);
16632 }
16633 Ok(())
16634 }
16635
16636 pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
16637 if !self.state.is_alive() {
16638 anyhow::bail!("you are dead");
16639 }
16640 self.seq += 1;
16641 self.session
16642 .submit_intent(Intent::EquipMainhand {
16643 entity_id: self.state.entity_id,
16644 template_id,
16645 instance_id: None,
16646 seq: self.seq,
16647 })
16648 .await?;
16649 self.state.intents_sent += 1;
16650 Ok(())
16651 }
16652
16653 pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
16655 let idx = self.state.equip_menu_index;
16656 let slots = equip_paperdoll_rows(&self.state);
16657 let Some(row) = slots.get(idx) else {
16658 return Ok(());
16659 };
16660 match row {
16661 EquipPaperdollRow::Body { slot, filled } => {
16662 if *filled {
16663 self.equip_worn(*slot, None).await
16664 } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
16665 self.equip_worn(*slot, Some(inst)).await
16666 } else {
16667 self.state
16668 .push_log(format!("No item for {}", body_slot_label(*slot)));
16669 Ok(())
16670 }
16671 }
16672 EquipPaperdollRow::Mainhand { filled } => {
16673 if *filled {
16674 self.unequip_mainhand().await
16675 } else if let Some(tid) = first_inventory_weapon(&self.state) {
16676 self.equip_mainhand(Some(tid)).await
16677 } else {
16678 self.state.push_log("No weapon in inventory".to_string());
16679 Ok(())
16680 }
16681 }
16682 EquipPaperdollRow::Offhand { filled, locked } => {
16683 if *locked {
16684 self.state
16685 .push_log("Offhand locked — two-handed weapon equipped".to_string());
16686 Ok(())
16687 } else if *filled {
16688 self.unequip_offhand().await
16689 } else if let Some(tid) = first_inventory_offhand(&self.state) {
16690 self.equip_offhand(Some(tid)).await
16691 } else {
16692 self.state
16693 .push_log("No offhand item in inventory".to_string());
16694 Ok(())
16695 }
16696 }
16697 }
16698 }
16699
16700 pub async fn say(
16701 &mut self,
16702 channel: flatland_protocol::ChatChannel,
16703 text: &str,
16704 ) -> anyhow::Result<()> {
16705 self.say_to(channel, text, None).await
16706 }
16707
16708 pub async fn say_to(
16709 &mut self,
16710 channel: flatland_protocol::ChatChannel,
16711 text: &str,
16712 to_entity: Option<EntityId>,
16713 ) -> anyhow::Result<()> {
16714 self.seq += 1;
16715 self.session
16716 .submit_intent(Intent::Say {
16717 entity_id: self.state.entity_id,
16718 channel,
16719 text: text.to_string(),
16720 to_entity,
16721 seq: self.seq,
16722 })
16723 .await?;
16724 self.state.intents_sent += 1;
16725 Ok(())
16726 }
16727
16728 pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
16729 let Some(peer) = self.state.player_verbs.target_entity else {
16730 return Ok(());
16731 };
16732 let label = self.state.player_verbs.target_label.clone();
16733 let choice = crate::social::PlayerVerbState::options()
16734 .get(self.state.player_verbs.index)
16735 .copied()
16736 .unwrap_or("Whisper");
16737 self.state.player_verbs.close();
16738 match choice {
16739 "Trade" => {
16740 self.seq += 1;
16743 self.session
16744 .submit_intent(Intent::TradeRequest {
16745 entity_id: self.state.entity_id,
16746 peer_entity_id: peer,
16747 seq: self.seq,
16748 })
16749 .await?;
16750 self.state.intents_sent += 1;
16751 self.state.social_chat.push_system(format!(
16752 "Trade request sent to {label} — waiting for accept"
16753 ));
16754 }
16755 "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
16756 _ => self.state.social_chat.focus_nearby(),
16757 }
16758 Ok(())
16759 }
16760
16761 pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
16762 let Some(pending) = self.state.social_chat.pending_trade.take() else {
16763 return Ok(());
16764 };
16765 self.seq += 1;
16766 self.session
16767 .submit_intent(Intent::TradeRespond {
16768 entity_id: self.state.entity_id,
16769 peer_entity_id: pending.from_entity,
16770 accept,
16771 seq: self.seq,
16772 })
16773 .await?;
16774 self.state.intents_sent += 1;
16775 if accept {
16776 self.state
16777 .social_chat
16778 .push_system(format!("Accepted trade with {}", pending.from_name));
16779 } else {
16780 self.state
16781 .social_chat
16782 .push_system(format!("Declined trade with {}", pending.from_name));
16783 }
16784 Ok(())
16785 }
16786
16787 pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
16788 let text = self.state.social_chat.buffer.trim().to_string();
16789 if text.is_empty() {
16790 return Ok(());
16791 }
16792 self.state.social_chat.buffer.clear();
16793 if crate::social::is_chat_slash_line(&text) {
16794 match crate::social::parse_chat_slash(&text) {
16795 Some(cmd) => return self.apply_chat_slash(cmd).await,
16796 None => {
16797 self.state.social_chat.push_system(format!(
16798 "Unknown command — {}",
16799 crate::social::chat_slash_help_text()
16800 ));
16801 return Ok(());
16802 }
16803 }
16804 }
16805 let thread = self.state.social_chat.thread;
16806 let channel = thread.channel();
16807 let to = thread.to_entity();
16808 if let Some(peer) = to {
16809 let label = self.state.social_chat.peer_label.clone();
16810 self.state
16811 .social_chat
16812 .remember_whisper_peer(peer, &label, channel);
16813 }
16814 self.say_to(channel, &text, to).await
16815 }
16816
16817 async fn apply_chat_slash(
16818 &mut self,
16819 cmd: crate::social::ChatSlashCommand,
16820 ) -> anyhow::Result<()> {
16821 use crate::social::{chat_slash_help_text, ChatSlashCommand};
16822 match cmd {
16823 ChatSlashCommand::Help => {
16824 self.state
16825 .social_chat
16826 .push_system(chat_slash_help_text().to_string());
16827 Ok(())
16828 }
16829 ChatSlashCommand::Nearby { message } => {
16830 self.state.social_chat.focus_nearby();
16831 self.state
16832 .social_chat
16833 .push_system("Nearby speech — everyone close can hear");
16834 if let Some(msg) = message {
16835 self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
16836 .await
16837 } else {
16838 Ok(())
16839 }
16840 }
16841 ChatSlashCommand::Reply { message } => {
16842 let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
16843 self.state
16844 .social_chat
16845 .push_system("No one to reply to — wait for a whisper, or /whisper Name");
16846 return Ok(());
16847 };
16848 let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
16849 self.state
16850 .social_chat
16851 .set_whisper_thread(peer.entity_id, &peer.label, stone);
16852 self.state.social_chat.push_system(format!(
16853 "Replying to {} — type and Enter · /nearby",
16854 peer.label
16855 ));
16856 if let Some(msg) = message {
16857 self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
16858 } else {
16859 Ok(())
16860 }
16861 }
16862 ChatSlashCommand::Whisper { name, message } => {
16863 let (peer_id, label, stone) = if let Some(name) = name {
16864 match self.resolve_whisper_target(&name) {
16865 Ok(t) => t,
16866 Err(err) => {
16867 self.state.social_chat.push_system(err);
16868 return Ok(());
16869 }
16870 }
16871 } else {
16872 let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
16873 self.state.social_chat.push_system(
16874 "Usage: /whisper Name [message] · or /reply after someone whispers you",
16875 );
16876 return Ok(());
16877 };
16878 (
16879 peer.entity_id,
16880 peer.label,
16881 peer.channel == flatland_protocol::ChatChannel::WhisperStone,
16882 )
16883 };
16884 self.state
16885 .social_chat
16886 .set_whisper_thread(peer_id, &label, stone);
16887 let channel = if stone {
16888 flatland_protocol::ChatChannel::WhisperStone
16889 } else {
16890 flatland_protocol::ChatChannel::Whisper
16891 };
16892 if let Some(msg) = message {
16893 self.state
16894 .social_chat
16895 .push_system(format!("Whisper → {label}"));
16896 self.say_to(channel, &msg, Some(peer_id)).await
16897 } else {
16898 self.state.social_chat.push_system(format!(
16899 "Whispering {label} — type and Enter · Esc / /nearby cancels"
16900 ));
16901 Ok(())
16902 }
16903 }
16904 }
16905 }
16906
16907 fn resolve_whisper_target(&self, name: &str) -> Result<(EntityId, String, bool), String> {
16909 let needle = name.trim().to_ascii_lowercase();
16910 if needle.is_empty() {
16911 return Err("Usage: /whisper Name [message]".into());
16912 }
16913 let mut candidates: Vec<(EntityId, String)> = self
16914 .state
16915 .entities
16916 .iter()
16917 .filter(|e| e.id != self.state.entity_id)
16918 .filter(|e| !e.label.trim().is_empty())
16919 .filter(|e| e.vitals.is_some())
16920 .filter(|e| !self.state.npcs.iter().any(|n| n.id == e.id.to_string()))
16921 .filter(|e| !self.state.hired_workers.iter().any(|w| w.entity_id == e.id))
16922 .map(|e| (e.id, e.label.clone()))
16923 .collect();
16924
16925 if let Some(last) = &self.state.social_chat.last_whisper_peer {
16927 if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
16928 candidates.push((last.entity_id, last.label.clone()));
16929 }
16930 }
16931
16932 let exact: Vec<_> = candidates
16933 .iter()
16934 .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
16935 .cloned()
16936 .collect();
16937 let pool = if exact.len() == 1 {
16938 exact
16939 } else if exact.len() > 1 {
16940 return Err(format!(
16941 "Several players named '{name}' nearby — move closer and try again"
16942 ));
16943 } else {
16944 let starts: Vec<_> = candidates
16945 .iter()
16946 .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
16947 .cloned()
16948 .collect();
16949 if starts.len() == 1 {
16950 starts
16951 } else if starts.len() > 1 {
16952 let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
16953 return Err(format!(
16954 "Ambiguous name '{name}' — matches: {}",
16955 names.join(", ")
16956 ));
16957 } else {
16958 let contains: Vec<_> = candidates
16959 .iter()
16960 .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
16961 .cloned()
16962 .collect();
16963 if contains.len() == 1 {
16964 contains
16965 } else if contains.is_empty() {
16966 return Err(format!(
16967 "No player matching '{name}' in range — get closer or check the spelling"
16968 ));
16969 } else {
16970 let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
16971 return Err(format!(
16972 "Ambiguous name '{name}' — matches: {}",
16973 names.join(", ")
16974 ));
16975 }
16976 }
16977 };
16978
16979 let (id, label) = pool.into_iter().next().unwrap();
16980 let stone = self
16981 .state
16982 .social_chat
16983 .last_whisper_peer
16984 .as_ref()
16985 .is_some_and(|p| {
16986 p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone
16987 });
16988 Ok((id, label, stone))
16989 }
16990
16991 pub async fn trade_present_selected(
16992 &mut self,
16993 item_instance_id: uuid::Uuid,
16994 ) -> anyhow::Result<()> {
16995 self.trade_present_quantity(item_instance_id, None).await
16996 }
16997
16998 pub async fn trade_present_quantity(
16999 &mut self,
17000 item_instance_id: uuid::Uuid,
17001 quantity: Option<u32>,
17002 ) -> anyhow::Result<()> {
17003 self.seq += 1;
17004 self.session
17005 .submit_intent(Intent::TradePresent {
17006 entity_id: self.state.entity_id,
17007 item_instance_id,
17008 quantity,
17009 seq: self.seq,
17010 })
17011 .await?;
17012 self.state.intents_sent += 1;
17013 self.state.trade_ui.qty_entry = None;
17014 self.state.trade_ui.picking_inventory = false;
17015 Ok(())
17016 }
17017
17018 pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
17020 if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
17021 let qty = self.state.trade_ui.present_quantity();
17022 return self
17023 .trade_present_quantity(entry.item_instance_id, qty)
17024 .await;
17025 }
17026 if !self.state.trade_ui.picking_inventory {
17027 return Ok(());
17028 }
17029 let stacks = self.state.trade_presentable_stacks();
17030 let Some(stack) = stacks.get(self.state.trade_ui.inventory_index).copied() else {
17031 return Ok(());
17032 };
17033 let Some(id) = stack.item_instance_id else {
17034 return Ok(());
17035 };
17036 let label = stack
17037 .display_name
17038 .clone()
17039 .unwrap_or_else(|| stack.template_id.clone());
17040 if stack.quantity <= 1 {
17041 self.trade_present_quantity(id, Some(1)).await
17042 } else {
17043 self.state
17044 .trade_ui
17045 .begin_qty_entry(id, label, stack.quantity);
17046 Ok(())
17047 }
17048 }
17049
17050 pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
17051 self.seq += 1;
17052 self.session
17053 .submit_intent(Intent::TradeSetReady {
17054 entity_id: self.state.entity_id,
17055 ready,
17056 seq: self.seq,
17057 })
17058 .await?;
17059 self.state.intents_sent += 1;
17060 Ok(())
17061 }
17062
17063 pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
17064 self.seq += 1;
17065 self.session
17066 .submit_intent(Intent::TradeCancel {
17067 entity_id: self.state.entity_id,
17068 seq: self.seq,
17069 })
17070 .await?;
17071 self.state.intents_sent += 1;
17072 self.state.trade_ui.close();
17073 Ok(())
17074 }
17075
17076 pub async fn destroy_whisper_stone(
17077 &mut self,
17078 item_instance_id: uuid::Uuid,
17079 ) -> anyhow::Result<()> {
17080 self.seq += 1;
17081 self.session
17082 .submit_intent(Intent::DestroyWhisperStone {
17083 entity_id: self.state.entity_id,
17084 item_instance_id,
17085 seq: self.seq,
17086 })
17087 .await?;
17088 self.state.intents_sent += 1;
17089 Ok(())
17090 }
17091
17092 pub async fn stop(&mut self) -> anyhow::Result<()> {
17093 self.seq += 1;
17094 self.session
17095 .submit_intent(Intent::Stop {
17096 entity_id: self.state.entity_id,
17097 seq: self.seq,
17098 })
17099 .await?;
17100 self.state.intents_sent += 1;
17101 Ok(())
17102 }
17103
17104 pub fn disconnect(&self) {
17105 self.session.disconnect();
17106 }
17107}
17108
17109fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
17110 let dx = ax - bx;
17111 let dy = ay - by;
17112 (dx * dx + dy * dy).sqrt()
17113}
17114
17115#[cfg(test)]
17116mod tests {
17117 use std::collections::BTreeMap;
17118
17119 use super::*;
17120 use flatland_protocol::{
17121 BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
17122 };
17123
17124 fn sample_state() -> GameState {
17125 let mut state = GameState {
17126 session_id: 1,
17127 entity_id: 1,
17128 character_id: None,
17129 tick: 0,
17130 chunk_rev: 0,
17131 content_rev: 0,
17132 publish_rev: 0,
17133 entities: vec![EntityState {
17134 id: 1,
17135 label: "You".into(),
17136 transform: Transform {
17137 position: WorldCoord::surface(128.0, 128.0),
17138 yaw: 0.0,
17139 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
17140 },
17141 vitals: None,
17142 attributes: None,
17143 skills: None,
17144 inside_building: None,
17145 tile_id: None,
17146 paperdoll_ref: None,
17147 draw_scale: 1.0,
17148 presentation_state: None,
17149 sprite_mode: None,
17150 progression_xp: None,
17151 combat_cues: vec![],
17152 statuses: vec![],
17153 }],
17154 player: None,
17155 resource_nodes: vec![ResourceNodeView {
17156 id: "oak-1".into(),
17157 label: "Oak".into(),
17158 x: 130.0,
17159 y: 128.0,
17160 z: 0.0,
17161 item_template: "oak_log".into(),
17162 state: ResourceNodeState::Available,
17163 blocking: true,
17164 blocking_radius_m: 0.8,
17165 harvest_off: false,
17166 tile_id: None,
17167 yaw: 0.0,
17168 pitch: 0.0,
17169 roll: 0.0,
17170 draw_scale: 1.0,
17171 sprite_mode: None,
17172 growth_progress: None,
17173 presentation_state: None,
17174 channel_start_tick: None,
17175 channel_end_tick: None,
17176 harvest_drop_templates: vec![],
17177 }],
17178 harvest_route_nodes: vec![],
17179 ground_drops: vec![],
17180 placed_containers: vec![],
17181 buildings: vec![BuildingView {
17182 id: "broker-hut".into(),
17183 label: "Broker".into(),
17184 x: 148.0,
17185 y: 118.0,
17186 width_m: 8.0,
17187 depth_m: 6.0,
17188 interior_blueprint: Some("broker_hut".into()),
17189 tags: vec![],
17190 market_boundary_zone_ids: vec![],
17191 market_max_volume: None,
17192 wall_set: None,
17193 roof_set: None,
17194 }],
17195 doors: vec![flatland_protocol::DoorView {
17196 id: "door-1".into(),
17197 building_id: "broker-hut".into(),
17198 x: 148.0,
17199 y: 118.0,
17200 open: false,
17201 portal: Some("front".into()),
17202 locked: false,
17203 accessible: true,
17204 lock_id: None,
17205 }],
17206 interior_map: None,
17207 npcs: vec![],
17208 blueprints: vec![],
17209 building_materials: vec![],
17210 world_x0: 0.0,
17211 world_y0: 0.0,
17212 world_width_m: 256.0,
17213 world_height_m: 256.0,
17214 terrain_zones: Vec::new(),
17215 z_platforms: Vec::new(),
17216 z_transitions: Vec::new(),
17217 z_bands_outdoor_backup: None,
17218 world_clock: flatland_protocol::WorldClock::default(),
17219 inventory: std::collections::HashMap::new(),
17220 inventory_hints: std::collections::HashMap::new(),
17221 item_catalog: std::collections::HashMap::new(),
17222 logs: VecDeque::new(),
17223 intents_sent: 0,
17224 ticks_received: 0,
17225 connected: true,
17226 disconnect_reason: None,
17227 show_stats: false,
17228 hud_log_hidden: false,
17229 show_equip_menu: false,
17230 equip_menu_index: 0,
17231 show_craft_menu: false,
17232 show_plot_build_menu: false,
17233 plot_build_focus_wall: true,
17234 plot_build_wall_index: 0,
17235 plot_build_roof_index: 0,
17236 craft_menu_index: 0,
17237 craft_batch_quantity: 1,
17238 craft_tab: CraftTab::Ready,
17239 craft_filter: String::new(),
17240 craft_filter_focused: false,
17241 craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
17242 show_shop_menu: false,
17243 shop_catalog: None,
17244 bank_panel: None,
17245 bank_menu_index: 0,
17246 bank_ui_mode: BankUiMode::Menu,
17247 storage_panel: None,
17248 market_panel: None,
17249 market_menu_index: 0,
17250 market_filter: String::new(),
17251 market_filter_focused: false,
17252 market_category_filter: None,
17253 market_buy_confirm: None,
17254 market_ui_mode: MarketUiMode::Browse,
17255 storage_menu_index: 0,
17256 storage_ui_mode: StorageUiMode::Menu,
17257 shop_tab: ShopTab::default(),
17258 shop_menu_index: 0,
17259 shop_quantity: 1,
17260 shop_trade_log: VecDeque::new(),
17261 show_npc_verb_menu: false,
17262 npc_verb_target: None,
17263 npc_verb_index: 0,
17264 npc_verb_notice: None,
17265 player_verbs: crate::social::PlayerVerbState::default(),
17266 social_chat: crate::social::SocialChatState::default(),
17267 trade_ui: crate::social::TradeUiState::default(),
17268 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
17269 show_npc_chat: false,
17270 npc_chat: None,
17271 show_inventory_menu: false,
17272 inventory_menu_index: 0,
17273 inventory_tab: InventoryTab::OnPerson,
17274 inventory_filter: String::new(),
17275 inventory_filter_focused: false,
17276 show_move_picker: false,
17277 show_rename_prompt: false,
17278 rename_plot_id: None,
17279 highlighted_plot_id: None,
17280 show_worker_rename: false,
17281 rename_buffer: String::new(),
17282 move_picker_index: 0,
17283 move_picker: None,
17284 show_grant_picker: false,
17285 grant_picker_index: 0,
17286 grant_picker: None,
17287 show_destroy_picker: false,
17288 destroy_confirm_pending: false,
17289 destroy_picker: None,
17290 show_deconstruct_picker: false,
17291 deconstruct_confirm_pending: false,
17292 deconstruct_picker: None,
17293 combat_target: None,
17294 combat_target_label: None,
17295 ground_target: None,
17296 combat_fx: Vec::new(),
17297 ground_hazards: Vec::new(),
17298 property_zones: Vec::new(),
17299 tax_zones: Vec::new(),
17300 growth_zones: Vec::new(),
17301 biome_zones: Vec::new(),
17302 terrain_kind_nav: Vec::new(),
17303 property_plots: Vec::new(),
17304 property_plot_settings: None,
17305 claim_mode: None,
17306 relocate_mode: None,
17307 sell_plot_confirm: None,
17308 sell_plot_armed_at: None,
17309 show_plant_menu: false,
17310 plant_menu_index: 0,
17311 show_farm_access: false,
17312 farm_access_name_draft: String::new(),
17313 farm_access_discount_bps: 0,
17314 farm_access_index: 0,
17315 plant_quantity: 1,
17316 in_combat: false,
17317 auto_attack: true,
17318 combat_has_los: false,
17319 attack_cd_ticks: 0,
17320 gcd_ticks: 0,
17321 weapon_ability_id: "unarmed".into(),
17322 mainhand_template_id: None,
17323 mainhand_label: None,
17324 mainhand_instance_id: None,
17325 offhand_template_id: None,
17326 offhand_label: None,
17327 offhand_instance_id: None,
17328 mainhand_hand_slots: 1,
17329 defense: None,
17330 worn: BTreeMap::new(),
17331 carry_mass: 0.0,
17332 carry_mass_max: 0.0,
17333 encumbrance: flatland_protocol::EncumbranceState::Light,
17334 move_speed_mps: 0.0,
17335 move_speed_mult: 0.0,
17336 inventory_stacks: Vec::new(),
17337 keychain_stacks: Vec::new(),
17338 whisper_pouch_stacks: Vec::new(),
17339 combat_target_detail: None,
17340 statuses: Vec::new(),
17341 cast_progress: None,
17342 timed_channel: None,
17343 plot_build_offer: None,
17344 ability_cooldowns: Vec::new(),
17345 blocking_active: false,
17346 max_target_slots: 1,
17347 combat_slots: Vec::new(),
17348 rotation_presets: Vec::new(),
17349 known_abilities: Vec::new(),
17350 ability_meta: std::collections::HashMap::new(),
17351 ability_mastery: std::collections::HashMap::new(),
17352 hotbar: vec![None; 9],
17353 max_abilities_per_rotation: 0,
17354 show_loadout_menu: false,
17355 show_keychain_menu: false,
17356 keychain_menu_index: 0,
17357 show_rotation_editor: false,
17358 loadout_menu_index: 0,
17359 loadout_hotbar_slot: 1,
17360 loadout_ability_index: 0,
17361 loadout_focus_presets: false,
17362 rotation_editor: RotationEditorState::default(),
17363 harvest_in_progress: false,
17364 harvest_started_at: None,
17365 pending_craft_ack: None,
17366 craft_channel_blueprint_id: None,
17367 craft_channel_seen: false,
17368 pending_worker_job_ack: None,
17369 attending_worker_instance_id: None,
17370 quest_log: Vec::new(),
17371 interactables: Vec::new(),
17372 ledger: None,
17373 career: None,
17374 character_sheet_tab: CharacterSheetTab::Character,
17375 ledger_period: LedgerPeriod::Day,
17376 show_quest_offer: false,
17377 pending_quest_offers: Vec::new(),
17378 quest_offer_index: 0,
17379 show_quest_menu: false,
17380 quest_menu_index: 0,
17381 quest_withdraw_confirm: false,
17382 hired_workers: Vec::new(),
17383 show_workers_menu: false,
17384 workers_menu_index: 0,
17385 worker_dismiss_confirmation: None,
17386 workers_menu_compact: false,
17387 worker_step_display: BTreeMap::new(),
17388 worker_error_display: BTreeMap::new(),
17389 worker_health_ring_until: BTreeMap::new(),
17390 pending_worker_hire_since: None,
17391 show_worker_give_picker: false,
17392 worker_give_picker_index: 0,
17393 worker_give_picker: None,
17394 show_worker_give_target_picker: false,
17395 worker_give_target_picker_index: 0,
17396 worker_give_target_picker: None,
17397 show_worker_take_picker: false,
17398 worker_take_picker_index: 0,
17399 worker_take_picker: None,
17400 show_worker_teach_picker: false,
17401 worker_teach_picker_index: 0,
17402 worker_teach_picker: None,
17403 worker_route_editor: None,
17404 progression_curve: None,
17405 };
17406 state.player = state.entities.first().cloned();
17407 state
17408 }
17409
17410 #[test]
17411 fn template_display_name_uses_item_catalog_for_uuid_ids() {
17412 let mut state = sample_state();
17413 let id = "7ac81ed5-e3d3-4b7f-a7fb-11d6c34a0995";
17414 assert_eq!(state.template_display_name(id), "Unknown item");
17415 state.item_catalog.insert(
17416 id.into(),
17417 ItemCatalogEntryView {
17418 template_id: id.into(),
17419 display_name: "Emerald".into(),
17420 category: "resource".into(),
17421 seed_for: None,
17422 },
17423 );
17424 assert_eq!(state.template_display_name(id), "Emerald");
17425 }
17426
17427 #[test]
17428 fn whisper_cancels_when_peer_walks_out_of_range() {
17429 let mut state = sample_state();
17430 state.player = state.entities.first().cloned();
17431 let mut peer = state.entities[0].clone();
17432 peer.id = 2;
17433 peer.label = "Ada".into();
17434 peer.transform.position = WorldCoord::surface(129.0, 128.0); state.entities.push(peer.clone());
17436 state.social_chat.focus_whisper(2, "Ada");
17437 state.refresh_whisper_range();
17438 assert!(matches!(
17439 state.social_chat.thread,
17440 crate::social::ChatThreadKind::Whisper { peer: 2 }
17441 ));
17442
17443 peer.transform.position = WorldCoord::surface(132.0, 128.0); state.entities[1] = peer;
17445 state.refresh_whisper_range();
17446 assert_eq!(
17447 state.social_chat.thread,
17448 crate::social::ChatThreadKind::Nearby
17449 );
17450 assert!(!state.social_chat.input_focused);
17451 }
17452
17453 #[test]
17454 fn probe_use_world_hired_worker_manage() {
17455 let mut state = sample_state();
17456 state
17457 .hired_workers
17458 .push(flatland_protocol::HiredWorkerView {
17459 instance_id: "worker-1".into(),
17460 entity_id: 42,
17461 def_id: "worker_laborer".into(),
17462 label: "Sam".into(),
17463 x: 129.0,
17464 y: 128.0,
17465 z: 0.0,
17466 mode: flatland_protocol::WorkerModeView::JobLoop,
17467 state: flatland_protocol::WorkerStateView::Working,
17468 step_label: "cultivate".into(),
17469 vitals: flatland_protocol::WorkerVitalsSummary {
17470 health_pct: 100.0,
17471 stamina_pct: 100.0,
17472 mana_pct: 100.0,
17473 hunger_pct: 100.0,
17474 thirst_pct: 100.0,
17475 },
17476 carry_pct: 0.0,
17477 last_error: None,
17478 wage_copper_per_interval: 1,
17479 effective_wage_copper: 1,
17480 wage_meters_walked: 0.0,
17481 lodging_container_id: None,
17482 route: None,
17483 route_stop_index: None,
17484 known_blueprint_ids: Vec::new(),
17485 level: 1,
17486 worker_xp: 0.0,
17487 inventory: Vec::new(),
17488 equipment: flatland_protocol::WorkerEquipmentView::default(),
17489 issue_hint: None,
17490 has_blocking_issue: false,
17491 harvest_node_issues: Vec::new(),
17492 });
17493 let probe = state.probe_use_world();
17494 let primary = probe.primary.expect("primary");
17495 assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
17496 assert_eq!(primary.id, "worker-1");
17497 assert!(primary.hint_line().contains("Manage"));
17498 assert!(primary.hint_line().contains("Sam"));
17499 assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
17500 }
17501
17502 fn sample_hired_worker(x: f32, y: f32) -> flatland_protocol::HiredWorkerView {
17503 flatland_protocol::HiredWorkerView {
17504 instance_id: "worker-1".into(),
17505 entity_id: 42,
17506 def_id: "worker_laborer".into(),
17507 label: "Sam".into(),
17508 x,
17509 y,
17510 z: 0.0,
17511 mode: flatland_protocol::WorkerModeView::JobLoop,
17512 state: flatland_protocol::WorkerStateView::Working,
17513 step_label: "follow".into(),
17514 vitals: flatland_protocol::WorkerVitalsSummary {
17515 health_pct: 100.0,
17516 stamina_pct: 100.0,
17517 mana_pct: 100.0,
17518 hunger_pct: 100.0,
17519 thirst_pct: 100.0,
17520 },
17521 carry_pct: 0.0,
17522 last_error: None,
17523 wage_copper_per_interval: 1,
17524 effective_wage_copper: 1,
17525 wage_meters_walked: 0.0,
17526 lodging_container_id: None,
17527 route: None,
17528 route_stop_index: None,
17529 known_blueprint_ids: Vec::new(),
17530 level: 1,
17531 worker_xp: 0.0,
17532 inventory: Vec::new(),
17533 equipment: flatland_protocol::WorkerEquipmentView::default(),
17534 issue_hint: None,
17535 has_blocking_issue: false,
17536 harvest_node_issues: Vec::new(),
17537 }
17538 }
17539
17540 #[test]
17541 fn probe_harvest_beats_closer_hired_worker() {
17542 let mut state = sample_state();
17543 state.resource_nodes[0].x = 129.0;
17544 state.resource_nodes[0].y = 128.0;
17545 state.hired_workers.push(sample_hired_worker(128.2, 128.0));
17546 let probe = state.probe_use_world();
17547 let primary = probe.primary.expect("primary");
17548 assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
17549 assert_eq!(primary.id, "oak-1");
17550 assert!(state.harvestable_node_in_range());
17551 assert_eq!(
17552 state.nearest_interact_target().as_deref(),
17553 Some("worker-1"),
17554 "harvest is not Interact — worker remains the interact target"
17555 );
17556 }
17557
17558 #[test]
17559 fn probe_door_beats_closer_hired_worker() {
17560 let mut state = sample_state();
17561 state.doors[0].x = 129.2;
17562 state.doors[0].y = 128.0;
17563 state.hired_workers.push(sample_hired_worker(128.3, 128.0));
17564 let probe = state.probe_use_world();
17565 let primary = probe.primary.expect("primary");
17566 assert!(
17567 matches!(
17568 primary.kind,
17569 crate::UseWorldKind::EnterDoor
17570 | crate::UseWorldKind::OpenDoor
17571 | crate::UseWorldKind::CloseDoor
17572 | crate::UseWorldKind::ExitDoor
17573 ),
17574 "door should win over closer worker, got {:?}",
17575 primary.kind
17576 );
17577 assert_eq!(primary.id, "door-1");
17578 assert_eq!(state.nearest_interact_target().as_deref(), Some("door-1"));
17579 }
17580
17581 #[test]
17582 fn probe_door_beats_worker_on_latch_approach() {
17583 let mut state = sample_state();
17587 state.entities[0].transform.position = WorldCoord::surface(128.0, 127.8);
17588 state.player = state.entities.first().cloned();
17589 state.doors[0].x = 128.0;
17590 state.doors[0].y = 130.0;
17591 state.hired_workers.push(sample_hired_worker(128.0, 129.1));
17592 let worker_dist = ((128.0_f32) - 128.0).hypot(129.1 - 127.8);
17593 let door_dist = ((128.0_f32) - 128.0).hypot(130.0 - 127.8);
17594 assert!(
17595 worker_dist <= 1.5 && door_dist > 2.0,
17596 "fixture must be worker-in / door-out of the old radii, got worker={worker_dist:.2} door={door_dist:.2}"
17597 );
17598 let probe = state.probe_use_world();
17599 let primary = probe.primary.expect("primary");
17600 assert!(
17601 matches!(
17602 primary.kind,
17603 crate::UseWorldKind::EnterDoor
17604 | crate::UseWorldKind::OpenDoor
17605 | crate::UseWorldKind::CloseDoor
17606 | crate::UseWorldKind::ExitDoor
17607 ),
17608 "door must win over latch-blocking worker, got {:?}",
17609 primary.kind
17610 );
17611 assert_eq!(primary.id, "door-1");
17612 assert_eq!(state.nearest_interact_target().as_deref(), Some("door-1"));
17613 }
17614
17615 #[test]
17616 fn probe_indoor_exit_door_beats_lodging_chest_pickup() {
17617 let mut state = sample_state();
17620 state.entities[0].inside_building = Some("player_house".into());
17621 state.entities[0].transform.position = WorldCoord::surface(5.0, 2.0);
17622 state.player = state.entities.first().cloned();
17623 state.buildings = vec![BuildingView {
17624 id: "player_house".into(),
17625 label: "MadSin's house".into(),
17626 x: 100.0,
17627 y: 100.0,
17628 width_m: 10.0,
17629 depth_m: 8.0,
17630 interior_blueprint: Some("player_house".into()),
17631 tags: vec!["player_built".into()],
17632 market_boundary_zone_ids: vec![],
17633 market_max_volume: None,
17634 wall_set: None,
17635 roof_set: None,
17636 }];
17637 state.doors = vec![flatland_protocol::DoorView {
17638 id: "house_exit".into(),
17639 building_id: "player_house".into(),
17640 x: 5.0,
17641 y: 1.0,
17642 open: true,
17643 portal: Some("front".into()),
17644 locked: false,
17645 accessible: true,
17646 lock_id: None,
17647 }];
17648 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
17649 id: "lodging_bed".into(),
17650 template_id: "camp_bed".into(),
17651 display_name: "Camp bed".into(),
17652 x: 5.5,
17653 y: 2.4,
17654 z: 0.0,
17655 locked: false,
17656 accessible: true,
17657 owner_character_id: None,
17658 contents: vec![],
17659 lock_id: None,
17660 capacity_volume: None,
17661 item_instance_id: Some(uuid::Uuid::from_u128(99)),
17662 tile_id: None,
17663 worker_lodging_capacity: Some(1),
17664 blocking: false,
17665 blocking_radius_m: 0.0,
17666 building_id: Some("player_house".into()),
17667 }];
17668 let mut worker = sample_hired_worker(40.0, 40.0);
17670 worker.lodging_container_id = Some("lodging_bed".into());
17671 state.hired_workers.push(worker);
17672
17673 let probe = state.probe_use_world();
17674 let primary = probe.primary.expect("primary");
17675 assert!(
17676 matches!(
17677 primary.kind,
17678 crate::UseWorldKind::ExitDoor
17679 | crate::UseWorldKind::OpenDoor
17680 | crate::UseWorldKind::CloseDoor
17681 | crate::UseWorldKind::EnterDoor
17682 ),
17683 "indoor exit must beat lodging ChestPickup, got {:?}",
17684 primary.kind
17685 );
17686 assert_eq!(primary.id, "house_exit");
17687 assert_eq!(primary.kind.cascade_stage(), 0);
17688 assert!(
17689 probe
17690 .candidates
17691 .iter()
17692 .any(|c| c.kind == crate::UseWorldKind::ChestPickup && c.in_range),
17693 "lodging bed should still be an in-range chest candidate"
17694 );
17695 assert_eq!(
17696 state.nearest_interact_target().as_deref(),
17697 Some("house_exit"),
17698 "use_nearest interact path should target the door"
17699 );
17700 assert!(state.lodging_is_occupied("lodging_bed"));
17701 }
17702
17703 #[test]
17704 fn probe_worker_when_no_resource_or_door_in_range() {
17705 let mut state = sample_state();
17706 state.hired_workers.push(sample_hired_worker(129.0, 128.0));
17708 let probe = state.probe_use_world();
17709 let primary = probe.primary.expect("primary");
17710 assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
17711 assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
17712 assert!(!state.harvestable_node_in_range());
17713 }
17714
17715 #[test]
17716 fn market_clerk_verb_options_include_market() {
17717 let mut state = sample_state();
17718 state.npcs.push(flatland_protocol::NpcView {
17719 id: "mira_market".into(),
17720 label: "Mira".into(),
17721 role: "market_clerk".into(),
17722 x: 129.0,
17723 y: 128.0,
17724 building_id: Some("town_market".into()),
17725 entity_id: None,
17726 life_state: None,
17727 hp_pct: None,
17728 can_trade: false,
17729 buy_templates: vec![],
17730 tile_id: None,
17731 behavior_state: None,
17732 presentation_state: None,
17733 sprite_mode: None,
17734 paperdoll_ref: None,
17735 draw_scale: 1.0,
17736 yaw: None,
17737 perception_fov_deg: None,
17738 perception_sight_m: None,
17739 perception_hear_m: None,
17740 quest_verbs: Vec::new(),
17741 });
17742 state.npc_verb_target = Some("mira_market".into());
17743 assert_eq!(
17744 state
17745 .npc_verb_options()
17746 .iter()
17747 .map(|v| v.label.as_str())
17748 .collect::<Vec<_>>(),
17749 vec!["Market", "Talk"]
17750 );
17751 }
17752
17753 #[test]
17754 fn butcher_verb_options_include_turn_in_for_give_item() {
17755 let mut state = sample_state();
17756 state.npcs.push(flatland_protocol::NpcView {
17757 id: "town_butcher_1".into(),
17758 label: "Brutus".into(),
17759 role: "butcher".into(),
17760 x: 129.0,
17761 y: 128.0,
17762 building_id: None,
17763 entity_id: None,
17764 life_state: None,
17765 hp_pct: None,
17766 can_trade: true,
17767 buy_templates: vec!["raw_venison".into()],
17768 tile_id: None,
17769 behavior_state: None,
17770 presentation_state: None,
17771 sprite_mode: None,
17772 paperdoll_ref: None,
17773 draw_scale: 1.0,
17774 yaw: None,
17775 perception_fov_deg: None,
17776 perception_sight_m: None,
17777 perception_hear_m: None,
17778 quest_verbs: Vec::new(),
17779 });
17780 state.quest_log.push(flatland_protocol::QuestLogEntry {
17781 quest_id: "deer_threat".into(),
17782 title: "Deer threat".into(),
17783 description: String::new(),
17784 status: flatland_protocol::QuestStatusView::Active,
17785 current_step_id: Some("deliver".into()),
17786 current_step_title: "Deliver venison".into(),
17787 current_step_index: 0,
17788 objectives: vec![flatland_protocol::QuestObjectiveProgress {
17789 label: "Give 3 Raw venison to Brutus".into(),
17790 current: 0,
17791 required: 3,
17792 done: false,
17793 kind: "give_item".into(),
17794 npc_ref: Some("town_butcher_1".into()),
17795 item_template: Some("raw_venison".into()),
17796 blueprint_id: None,
17797 building_id: None,
17798 }],
17799 current_step_reward: flatland_protocol::QuestRewardView::default(),
17800 completion_reward: flatland_protocol::QuestRewardView::default(),
17801 steps: Vec::new(),
17802 is_tracked: true,
17803 can_withdraw: true,
17804 });
17805 state.npc_verb_target = Some("town_butcher_1".into());
17806 assert_eq!(
17807 state
17808 .npc_verb_options()
17809 .iter()
17810 .map(|v| v.label.as_str())
17811 .collect::<Vec<_>>(),
17812 vec!["Turn in: Deer threat", "Talk", "Trade"]
17813 );
17814 }
17815
17816 #[test]
17817 fn ada_verb_options_include_quest_offer() {
17818 let mut state = sample_state();
17819 state.npcs.push(flatland_protocol::NpcView {
17820 id: "ada_broker".into(),
17821 label: "Ada".into(),
17822 role: "broker".into(),
17823 x: 129.0,
17824 y: 128.0,
17825 building_id: None,
17826 entity_id: None,
17827 life_state: None,
17828 hp_pct: None,
17829 can_trade: true,
17830 buy_templates: vec![],
17831 tile_id: None,
17832 behavior_state: None,
17833 presentation_state: None,
17834 sprite_mode: None,
17835 paperdoll_ref: Some("ada_broker".into()),
17836 draw_scale: 1.0,
17837 yaw: None,
17838 perception_fov_deg: None,
17839 perception_sight_m: None,
17840 perception_hear_m: None,
17841 quest_verbs: vec![flatland_protocol::NpcQuestVerb {
17842 quest_id: "ada_goblin_hunt".into(),
17843 label: "Ask about goblins".into(),
17844 kind: flatland_protocol::NpcQuestVerb::KIND_OFFER.into(),
17845 }],
17846 });
17847 state.npc_verb_target = Some("ada_broker".into());
17848 assert_eq!(
17849 state
17850 .npc_verb_options()
17851 .iter()
17852 .map(|v| v.label.as_str())
17853 .collect::<Vec<_>>(),
17854 vec!["Ask about goblins", "Talk", "Trade"]
17855 );
17856 }
17857
17858 #[test]
17859 fn market_list_excludes_currency_stacks() {
17860 let mut state = sample_state();
17861 state.inventory_stacks = vec![
17862 flatland_protocol::ItemStack {
17863 template_id: "copper_coin".into(),
17864 quantity: 50,
17865 item_instance_id: Some(uuid::Uuid::from_u128(10)),
17866 display_name: Some("Copper Coin".into()),
17867 ..Default::default()
17868 },
17869 flatland_protocol::ItemStack {
17870 template_id: "oak_log".into(),
17871 quantity: 2,
17872 item_instance_id: Some(uuid::Uuid::from_u128(11)),
17873 display_name: Some("Oak Log".into()),
17874 ..Default::default()
17875 },
17876 flatland_protocol::ItemStack {
17877 template_id: "whisper_stone".into(),
17878 quantity: 1,
17879 item_instance_id: Some(uuid::Uuid::from_u128(12)),
17880 display_name: Some("Whisper Stone".into()),
17881 category: Some("quest".into()),
17882 listable: Some(false),
17883 ..Default::default()
17884 },
17885 ];
17886 let opts = state.market_list_item_options(&MarketListSourceKind::Person);
17887 assert_eq!(opts.len(), 1);
17888 assert!(opts[0].label.contains("Oak"));
17889 }
17890
17891 #[test]
17892 fn market_browse_filters_by_category_and_search() {
17893 let mut state = sample_state();
17894 state.market_panel = Some(flatland_protocol::MarketPanel {
17895 npc_id: "mira_market".into(),
17896 npc_label: "Mira".into(),
17897 building_id: "town_market".into(),
17898 building_label: "Town Market".into(),
17899 used_volume: 0.0,
17900 max_volume: 100.0,
17901 listings: vec![
17902 flatland_protocol::MarketListingView {
17903 listing_id: uuid::Uuid::from_u128(1),
17904 seller_character_id: uuid::Uuid::from_u128(2),
17905 seller_label: "Ada".into(),
17906 hall_building_id: "town_market".into(),
17907 hall_label: "Town Market".into(),
17908 template_id: "oak_log".into(),
17909 display_name: "Oak Log".into(),
17910 category: "resource".into(),
17911 quantity: 3,
17912 unit_price_copper: 10,
17913 line_total_copper: 30,
17914 npc_price: false,
17915 npc_dump_unit_copper: None,
17916 mine: false,
17917 },
17918 flatland_protocol::MarketListingView {
17919 listing_id: uuid::Uuid::from_u128(3),
17920 seller_character_id: uuid::Uuid::from_u128(2),
17921 seller_label: "Ada".into(),
17922 hall_building_id: "town_market".into(),
17923 hall_label: "Town Market".into(),
17924 template_id: "short_sword".into(),
17925 display_name: "Short Sword".into(),
17926 category: "weapon".into(),
17927 quantity: 1,
17928 unit_price_copper: 100,
17929 line_total_copper: 100,
17930 npc_price: false,
17931 npc_dump_unit_copper: None,
17932 mine: false,
17933 },
17934 ],
17935 tax_bps: 0,
17936 tax_flat_copper: 0,
17937 list_vaults: vec![],
17938 });
17939 assert_eq!(state.market_filtered_listing_indices().len(), 2);
17940 state.market_category_filter = Some("Weapons");
17941 let weapons = state.market_filtered_listing_indices();
17942 assert_eq!(weapons.len(), 1);
17943 assert_eq!(
17944 state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
17945 "Short Sword"
17946 );
17947 state.market_category_filter = None;
17948 state.market_filter = "oak".into();
17949 let oak = state.market_filtered_listing_indices();
17950 assert_eq!(oak.len(), 1);
17951 assert_eq!(
17952 state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
17953 "Oak Log"
17954 );
17955 }
17956
17957 #[test]
17958 fn market_list_source_includes_person_and_vaults() {
17959 let mut state = sample_state();
17960 let item_id = uuid::Uuid::from_u128(1);
17961 state.inventory_stacks = vec![flatland_protocol::ItemStack {
17962 template_id: "oak_log".into(),
17963 quantity: 2,
17964 item_instance_id: Some(item_id),
17965 display_name: Some("Oak Log".into()),
17966 ..Default::default()
17967 }];
17968 state.market_panel = Some(flatland_protocol::MarketPanel {
17969 npc_id: "mira_market".into(),
17970 npc_label: "Mira".into(),
17971 building_id: "town_market".into(),
17972 building_label: "Town Market".into(),
17973 used_volume: 0.0,
17974 max_volume: 100.0,
17975 listings: vec![],
17976 tax_bps: 0,
17977 tax_flat_copper: 0,
17978 list_vaults: vec![flatland_protocol::MarketListVault {
17979 building_id: "town_storage".into(),
17980 building_label: "Town Storage".into(),
17981 contents: vec![flatland_protocol::ItemStack {
17982 template_id: "lumber".into(),
17983 quantity: 1,
17984 item_instance_id: Some(uuid::Uuid::from_u128(2)),
17985 display_name: Some("Lumber".into()),
17986 ..Default::default()
17987 }],
17988 }],
17989 });
17990 let sources = state.market_list_source_options();
17991 assert_eq!(sources.len(), 2);
17992 assert!(matches!(sources[0].0, MarketListSourceKind::Person));
17993 assert!(matches!(
17994 sources[1].0,
17995 MarketListSourceKind::TownStorage { .. }
17996 ));
17997 assert!(sources[1].1.contains("Town Storage"));
17998 }
17999
18000 #[test]
18001 fn npc_market_dump_estimate_from_town_storage_vault() {
18002 let mut state = sample_state();
18003 state.market_panel = Some(flatland_protocol::MarketPanel {
18004 npc_id: "mira_market".into(),
18005 npc_label: "Mira".into(),
18006 building_id: "town_market".into(),
18007 building_label: "Town Market".into(),
18008 used_volume: 0.0,
18009 max_volume: 100.0,
18010 listings: vec![],
18011 tax_bps: 0,
18012 tax_flat_copper: 0,
18013 list_vaults: vec![flatland_protocol::MarketListVault {
18014 building_id: "town_storage".into(),
18015 building_label: "Town Storage".into(),
18016 contents: vec![flatland_protocol::ItemStack {
18017 template_id: "lumber".into(),
18018 quantity: 3,
18019 item_instance_id: Some(uuid::Uuid::from_u128(2)),
18020 display_name: Some("Lumber".into()),
18021 base_value_copper: Some(20),
18022 ..Default::default()
18023 }],
18024 }],
18025 });
18026 assert_eq!(
18027 state.npc_market_dump_unit_estimate("lumber"),
18028 Some(9),
18029 "vault stack base_value should enable NPC price estimate"
18030 );
18031 }
18032
18033 #[test]
18034 fn probe_use_world_npc_beats_nearby_loot() {
18035 let mut state = sample_state();
18036 state.npcs.push(flatland_protocol::NpcView {
18037 id: "ada".into(),
18038 label: "Ada".into(),
18039 role: "broker".into(),
18040 x: 129.0,
18041 y: 128.0,
18042 building_id: None,
18043 entity_id: None,
18044 life_state: None,
18045 hp_pct: None,
18046 can_trade: true,
18047 buy_templates: vec!["lumber".into()],
18048 tile_id: None,
18049 behavior_state: None,
18050 presentation_state: None,
18051 sprite_mode: None,
18052 paperdoll_ref: None,
18053 draw_scale: 1.0,
18054 yaw: None,
18055 perception_fov_deg: None,
18056 perception_sight_m: None,
18057 perception_hear_m: None,
18058 quest_verbs: Vec::new(),
18059 });
18060 state.ground_drops.push(flatland_protocol::GroundDropView {
18061 id: "d1".into(),
18062 template_id: "lumber".into(),
18063 quantity: 1,
18064 x: 128.5,
18065 y: 128.0,
18066 z: 0.0,
18067 tile_id: None,
18068 display_name: None,
18069 yaw: 0.0,
18070 pitch: 0.0,
18071 roll: 0.0,
18072 draw_scale: 1.0,
18073 item_instance_id: None,
18074 props: Default::default(),
18075 status_bindings: Vec::new(),
18076 });
18077 let probe = state.probe_use_world();
18078 let primary = probe.primary.expect("primary");
18079 assert_eq!(primary.kind, crate::UseWorldKind::Npc);
18080 assert_eq!(primary.id, "ada");
18081 }
18082
18083 #[test]
18084 fn probe_use_world_harvest_when_in_range() {
18085 let state = sample_state(); let probe = state.probe_use_world();
18087 assert!(
18088 probe.primary.is_none(),
18089 "oak is 2m away, out of harvest range"
18090 );
18091 assert!(probe
18092 .candidates
18093 .iter()
18094 .any(|c| c.kind == crate::UseWorldKind::Harvest));
18095
18096 let mut state = sample_state();
18097 state.resource_nodes[0].x = 129.0;
18098 let probe = state.probe_use_world();
18099 let primary = probe.primary.expect("primary");
18100 assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
18101 }
18102
18103 #[test]
18104 fn probe_use_world_door_uses_building_label() {
18105 let mut state = sample_state();
18106 state.doors[0].x = 129.0;
18107 state.doors[0].y = 128.0;
18108 let probe = state.probe_use_world();
18109 let primary = probe.primary.expect("primary");
18110 assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
18111 assert_eq!(primary.label, "Broker");
18112 assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
18113 }
18114
18115 #[test]
18116 fn empty_entity_tick_preserves_welcome_snapshot() {
18117 let mut state = sample_state();
18118 state.inventory.insert("carrot".into(), 3);
18119 let delta = TickDelta {
18120 tick: 1,
18121 entities: vec![],
18122 resource_nodes: vec![],
18123 ground_drops: vec![],
18124 placed_containers: vec![],
18125 buildings: vec![],
18126 doors: vec![],
18127 interior_map: None,
18128 npcs: vec![],
18129 inventory: vec![],
18130 blueprints: vec![],
18131 building_materials: vec![],
18132 world_clock: flatland_protocol::WorldClock::default(),
18133 combat: None,
18134 quest_log: vec![],
18135 hired_workers: Vec::new(),
18136 interactables: vec![],
18137 ledger: None,
18138 career: None,
18139 combat_fx: Vec::new(),
18140 ground_hazards: Vec::new(),
18141 property_plots: Vec::new(),
18142 terrain_overlays: Vec::new(),
18143 };
18144
18145 state.apply_tick_fields(&delta, 1);
18146
18147 assert_eq!(state.entities.len(), 1);
18148 assert!(state.player.is_some());
18149 assert_eq!(state.inventory.get("carrot"), Some(&3));
18150 assert_eq!(state.resource_nodes.len(), 1);
18151 }
18152
18153 #[test]
18154 fn tick_preserves_world_layers_when_delta_omits_them() {
18155 let mut state = sample_state();
18156 let delta = TickDelta {
18157 tick: 1,
18158 entities: state.entities.clone(),
18159 resource_nodes: vec![],
18160 ground_drops: vec![],
18161 placed_containers: vec![],
18162 buildings: vec![],
18163 doors: vec![],
18164 interior_map: None,
18165 npcs: vec![],
18166 inventory: vec![],
18167 blueprints: vec![],
18168 building_materials: vec![],
18169 world_clock: flatland_protocol::WorldClock::default(),
18170 combat: None,
18171 quest_log: vec![],
18172 hired_workers: Vec::new(),
18173 interactables: vec![],
18174 ledger: None,
18175 career: None,
18176 combat_fx: Vec::new(),
18177 ground_hazards: Vec::new(),
18178 property_plots: Vec::new(),
18179 terrain_overlays: Vec::new(),
18180 };
18181
18182 state.apply_tick_fields(&delta, 1);
18183
18184 assert_eq!(state.resource_nodes.len(), 1);
18185 assert_eq!(state.buildings.len(), 1);
18186 assert_eq!(state.doors.len(), 1);
18187 }
18188
18189 #[test]
18190 fn tick_updates_resource_nodes_when_server_sends_them() {
18191 let mut state = sample_state();
18192 let delta = TickDelta {
18193 tick: 1,
18194 entities: state.entities.clone(),
18195 resource_nodes: vec![ResourceNodeView {
18196 id: "oak-1".into(),
18197 label: "Oak".into(),
18198 x: 130.0,
18199 y: 128.0,
18200 z: 0.0,
18201 item_template: "oak_log".into(),
18202 state: ResourceNodeState::Cooldown,
18203 blocking: true,
18204 blocking_radius_m: 0.8,
18205 harvest_off: false,
18206 tile_id: None,
18207 yaw: 0.0,
18208 pitch: 0.0,
18209 roll: 0.0,
18210 draw_scale: 1.0,
18211 sprite_mode: None,
18212 growth_progress: None,
18213 presentation_state: None,
18214 channel_start_tick: None,
18215 channel_end_tick: None,
18216 harvest_drop_templates: vec![],
18217 }],
18218 buildings: vec![],
18219 doors: vec![],
18220 interior_map: None,
18221 npcs: vec![],
18222 inventory: vec![],
18223 blueprints: vec![],
18224 building_materials: vec![],
18225 world_clock: flatland_protocol::WorldClock::default(),
18226 ground_drops: vec![],
18227 placed_containers: vec![],
18228 combat: None,
18229 quest_log: vec![],
18230 hired_workers: Vec::new(),
18231 interactables: vec![],
18232 ledger: None,
18233 career: None,
18234 combat_fx: Vec::new(),
18235 ground_hazards: Vec::new(),
18236 property_plots: Vec::new(),
18237 terrain_overlays: Vec::new(),
18238 };
18239
18240 state.apply_tick_fields(&delta, 1);
18241
18242 assert!(matches!(
18243 state.resource_nodes[0].state,
18244 ResourceNodeState::Cooldown
18245 ));
18246 }
18247
18248 #[test]
18249 fn harvest_picker_keeps_welcome_nodes_after_aoi_tick() {
18250 let mut state = sample_state();
18251 let nearby = state.resource_nodes[0].clone();
18252 let mut far = nearby.clone();
18253 far.id = "far-oak".into();
18254 far.label = "Far Oak".into();
18255 far.x = 200.0;
18256 far.y = 200.0;
18257 state.replace_harvest_route_nodes(&[nearby.clone(), far.clone()]);
18258
18259 let delta = TickDelta {
18260 tick: 1,
18261 entities: state.entities.clone(),
18262 resource_nodes: vec![nearby],
18263 ground_drops: vec![],
18264 placed_containers: vec![],
18265 buildings: vec![],
18266 doors: vec![],
18267 interior_map: None,
18268 npcs: vec![],
18269 inventory: vec![],
18270 blueprints: vec![],
18271 building_materials: vec![],
18272 world_clock: flatland_protocol::WorldClock::default(),
18273 combat: None,
18274 quest_log: vec![],
18275 hired_workers: Vec::new(),
18276 interactables: vec![],
18277 ledger: None,
18278 career: None,
18279 combat_fx: Vec::new(),
18280 ground_hazards: Vec::new(),
18281 property_plots: Vec::new(),
18282 terrain_overlays: Vec::new(),
18283 };
18284 state.apply_tick_fields(&delta, 1);
18285
18286 assert_eq!(state.resource_nodes.len(), 1);
18287 let ids: Vec<_> = state
18288 .route_editor_node_candidates()
18289 .into_iter()
18290 .map(|n| n.id)
18291 .collect();
18292 assert!(ids.contains(&"oak-1".to_string()), "got {ids:?}");
18293 assert!(ids.contains(&"far-oak".to_string()), "got {ids:?}");
18294 }
18295
18296 #[test]
18297 fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
18298 let mut state = GameState {
18299 session_id: 1,
18300 entity_id: 1,
18301 character_id: None,
18302 tick: 0,
18303 chunk_rev: 0,
18304 content_rev: 0,
18305 publish_rev: 0,
18306 entities: vec![EntityState {
18307 id: 1,
18308 label: "You".into(),
18309 transform: Transform {
18310 position: WorldCoord::surface(4.5, 2.0),
18311 yaw: 0.0,
18312 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
18313 },
18314 vitals: None,
18315 attributes: None,
18316 skills: None,
18317 inside_building: Some("broker_hut".into()),
18318 tile_id: None,
18319 paperdoll_ref: None,
18320 draw_scale: 1.0,
18321 presentation_state: None,
18322 sprite_mode: None,
18323 progression_xp: None,
18324 combat_cues: vec![],
18325 statuses: vec![],
18326 }],
18327 player: None,
18328 resource_nodes: vec![],
18329 harvest_route_nodes: vec![],
18330 ground_drops: vec![],
18331 placed_containers: vec![],
18332 buildings: vec![BuildingView {
18333 id: "broker_hut".into(),
18334 label: "Broker".into(),
18335 x: 158.0,
18336 y: 124.0,
18337 width_m: 8.0,
18338 depth_m: 6.0,
18339 interior_blueprint: Some("broker_hut".into()),
18340 tags: vec![],
18341 market_boundary_zone_ids: vec![],
18342 market_max_volume: None,
18343 wall_set: None,
18344 roof_set: None,
18345 }],
18346 doors: vec![flatland_protocol::DoorView {
18347 id: "broker_hut_exit".into(),
18348 building_id: "broker_hut".into(),
18349 x: 4.3,
18350 y: 0.9,
18351 open: true,
18352 portal: Some("front".into()),
18353 locked: false,
18354 accessible: true,
18355 lock_id: None,
18356 }],
18357 interior_map: None,
18358 npcs: vec![flatland_protocol::NpcView {
18359 id: "ada_broker".into(),
18360 label: "Ada".into(),
18361 x: 4.5,
18362 y: 2.0,
18363 building_id: Some("broker_hut".into()),
18364 role: "broker".into(),
18365 entity_id: None,
18366 life_state: None,
18367 hp_pct: None,
18368 can_trade: true,
18369 buy_templates: vec!["lumber".into()],
18370 tile_id: None,
18371 behavior_state: None,
18372 presentation_state: None,
18373 sprite_mode: None,
18374 paperdoll_ref: None,
18375 draw_scale: 1.0,
18376 yaw: None,
18377 perception_fov_deg: None,
18378 perception_sight_m: None,
18379 perception_hear_m: None,
18380 quest_verbs: Vec::new(),
18381 }],
18382 blueprints: vec![],
18383 building_materials: vec![],
18384 world_x0: 0.0,
18385 world_y0: 0.0,
18386 world_width_m: 256.0,
18387 world_height_m: 256.0,
18388 terrain_zones: Vec::new(),
18389 z_platforms: Vec::new(),
18390 z_transitions: Vec::new(),
18391 z_bands_outdoor_backup: None,
18392 world_clock: flatland_protocol::WorldClock::default(),
18393 inventory: std::collections::HashMap::new(),
18394 inventory_hints: std::collections::HashMap::new(),
18395 item_catalog: std::collections::HashMap::new(),
18396 logs: VecDeque::new(),
18397 intents_sent: 0,
18398 ticks_received: 0,
18399 connected: true,
18400 disconnect_reason: None,
18401 show_stats: false,
18402 hud_log_hidden: false,
18403 show_equip_menu: false,
18404 equip_menu_index: 0,
18405 show_craft_menu: false,
18406 show_plot_build_menu: false,
18407 plot_build_focus_wall: true,
18408 plot_build_wall_index: 0,
18409 plot_build_roof_index: 0,
18410 craft_menu_index: 0,
18411 craft_batch_quantity: 1,
18412 craft_tab: CraftTab::Ready,
18413 craft_filter: String::new(),
18414 craft_filter_focused: false,
18415 craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
18416 show_shop_menu: false,
18417 shop_catalog: None,
18418 bank_panel: None,
18419 bank_menu_index: 0,
18420 bank_ui_mode: BankUiMode::Menu,
18421 storage_panel: None,
18422 market_panel: None,
18423 market_menu_index: 0,
18424 market_filter: String::new(),
18425 market_filter_focused: false,
18426 market_category_filter: None,
18427 market_buy_confirm: None,
18428 market_ui_mode: MarketUiMode::Browse,
18429 storage_menu_index: 0,
18430 storage_ui_mode: StorageUiMode::Menu,
18431 shop_tab: ShopTab::default(),
18432 shop_menu_index: 0,
18433 shop_quantity: 1,
18434 shop_trade_log: VecDeque::new(),
18435 show_npc_verb_menu: false,
18436 npc_verb_target: None,
18437 npc_verb_index: 0,
18438 npc_verb_notice: None,
18439 player_verbs: crate::social::PlayerVerbState::default(),
18440 social_chat: crate::social::SocialChatState::default(),
18441 trade_ui: crate::social::TradeUiState::default(),
18442 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
18443 show_npc_chat: false,
18444 npc_chat: None,
18445 show_inventory_menu: false,
18446 inventory_menu_index: 0,
18447 inventory_tab: InventoryTab::OnPerson,
18448 inventory_filter: String::new(),
18449 inventory_filter_focused: false,
18450 show_move_picker: false,
18451 show_rename_prompt: false,
18452 rename_plot_id: None,
18453 highlighted_plot_id: None,
18454 show_worker_rename: false,
18455 rename_buffer: String::new(),
18456 move_picker_index: 0,
18457 move_picker: None,
18458 show_grant_picker: false,
18459 grant_picker_index: 0,
18460 grant_picker: None,
18461 show_destroy_picker: false,
18462 destroy_confirm_pending: false,
18463 destroy_picker: None,
18464 show_deconstruct_picker: false,
18465 deconstruct_confirm_pending: false,
18466 deconstruct_picker: None,
18467 combat_target: None,
18468 combat_target_label: None,
18469 ground_target: None,
18470 combat_fx: Vec::new(),
18471 ground_hazards: Vec::new(),
18472 property_zones: Vec::new(),
18473 tax_zones: Vec::new(),
18474 growth_zones: Vec::new(),
18475 biome_zones: Vec::new(),
18476 terrain_kind_nav: Vec::new(),
18477 property_plots: Vec::new(),
18478 property_plot_settings: None,
18479 claim_mode: None,
18480 relocate_mode: None,
18481 sell_plot_confirm: None,
18482 sell_plot_armed_at: None,
18483 show_plant_menu: false,
18484 plant_menu_index: 0,
18485 show_farm_access: false,
18486 farm_access_name_draft: String::new(),
18487 farm_access_discount_bps: 0,
18488 farm_access_index: 0,
18489 plant_quantity: 1,
18490 in_combat: false,
18491 auto_attack: true,
18492 combat_has_los: false,
18493 attack_cd_ticks: 0,
18494 gcd_ticks: 0,
18495 weapon_ability_id: "unarmed".into(),
18496 mainhand_template_id: None,
18497 mainhand_label: None,
18498 mainhand_instance_id: None,
18499 offhand_template_id: None,
18500 offhand_label: None,
18501 offhand_instance_id: None,
18502 mainhand_hand_slots: 1,
18503 defense: None,
18504 worn: BTreeMap::new(),
18505 carry_mass: 0.0,
18506 carry_mass_max: 0.0,
18507 encumbrance: flatland_protocol::EncumbranceState::Light,
18508 move_speed_mps: 0.0,
18509 move_speed_mult: 0.0,
18510 inventory_stacks: Vec::new(),
18511 keychain_stacks: Vec::new(),
18512 whisper_pouch_stacks: Vec::new(),
18513 combat_target_detail: None,
18514 statuses: Vec::new(),
18515 cast_progress: None,
18516 timed_channel: None,
18517 plot_build_offer: None,
18518 ability_cooldowns: Vec::new(),
18519 blocking_active: false,
18520 max_target_slots: 1,
18521 combat_slots: Vec::new(),
18522 rotation_presets: Vec::new(),
18523 known_abilities: Vec::new(),
18524 ability_meta: std::collections::HashMap::new(),
18525 ability_mastery: std::collections::HashMap::new(),
18526 hotbar: vec![None; 9],
18527 max_abilities_per_rotation: 0,
18528 show_loadout_menu: false,
18529 show_keychain_menu: false,
18530 keychain_menu_index: 0,
18531 show_rotation_editor: false,
18532 loadout_menu_index: 0,
18533 loadout_hotbar_slot: 1,
18534 loadout_ability_index: 0,
18535 loadout_focus_presets: false,
18536 rotation_editor: RotationEditorState::default(),
18537 harvest_in_progress: false,
18538 harvest_started_at: None,
18539 pending_craft_ack: None,
18540 craft_channel_blueprint_id: None,
18541 craft_channel_seen: false,
18542 pending_worker_job_ack: None,
18543 attending_worker_instance_id: None,
18544 quest_log: Vec::new(),
18545 interactables: Vec::new(),
18546 ledger: None,
18547 career: None,
18548 character_sheet_tab: CharacterSheetTab::Character,
18549 ledger_period: LedgerPeriod::Day,
18550 show_quest_offer: false,
18551 pending_quest_offers: Vec::new(),
18552 quest_offer_index: 0,
18553 show_quest_menu: false,
18554 quest_menu_index: 0,
18555 quest_withdraw_confirm: false,
18556 hired_workers: Vec::new(),
18557 show_workers_menu: false,
18558 workers_menu_index: 0,
18559 worker_dismiss_confirmation: None,
18560 workers_menu_compact: false,
18561 worker_step_display: BTreeMap::new(),
18562 worker_error_display: BTreeMap::new(),
18563 worker_health_ring_until: BTreeMap::new(),
18564 pending_worker_hire_since: None,
18565 show_worker_give_picker: false,
18566 worker_give_picker_index: 0,
18567 worker_give_picker: None,
18568 show_worker_give_target_picker: false,
18569 worker_give_target_picker_index: 0,
18570 worker_give_target_picker: None,
18571 show_worker_take_picker: false,
18572 worker_take_picker_index: 0,
18573 worker_take_picker: None,
18574 show_worker_teach_picker: false,
18575 worker_teach_picker_index: 0,
18576 worker_teach_picker: None,
18577 worker_route_editor: None,
18578 progression_curve: None,
18579 };
18580 state.player = state.entities.first().cloned();
18581 assert_eq!(
18582 state.nearest_interact_target().as_deref(),
18583 Some("ada_broker")
18584 );
18585 }
18586
18587 #[test]
18588 fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
18589 let mut state = sample_state();
18590 state.placed_containers = vec![
18593 flatland_protocol::PlacedContainerView {
18594 id: "near".into(),
18595 template_id: "wooden_chest_small".into(),
18596 display_name: "Wooden Chest".into(),
18597 x: 130.0,
18598 y: 128.0,
18599 z: 0.0,
18600 locked: true,
18601 accessible: true,
18602 owner_character_id: None,
18603 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
18604 lock_id: None,
18605 capacity_volume: None,
18606 item_instance_id: Some(uuid::Uuid::from_u128(1)),
18607 tile_id: None,
18608 worker_lodging_capacity: None,
18609 blocking: false,
18610 blocking_radius_m: 0.0,
18611 building_id: None,
18612 },
18613 flatland_protocol::PlacedContainerView {
18614 id: "far".into(),
18615 template_id: "wooden_chest_small".into(),
18616 display_name: "Distant Chest".into(),
18617 x: 128.0 + CONTAINER_RANGE_M + 5.0,
18618 y: 128.0,
18619 z: 0.0,
18620 locked: false,
18621 accessible: true,
18622 owner_character_id: None,
18623 contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
18624 lock_id: None,
18625 capacity_volume: None,
18626 item_instance_id: Some(uuid::Uuid::from_u128(2)),
18627 tile_id: None,
18628 worker_lodging_capacity: None,
18629 blocking: false,
18630 blocking_radius_m: 0.0,
18631 building_id: None,
18632 },
18633 ];
18634
18635 let nearby = state.nearby_containers();
18636 assert_eq!(
18637 nearby.len(),
18638 1,
18639 "far chest must not appear once out of range"
18640 );
18641 assert_eq!(nearby[0].view.id, "near");
18642 assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
18643 assert!(nearby[0].rows[0].is_chest_shell);
18644
18645 state.placed_containers[0].accessible = false;
18648 let nearby = state.nearby_containers();
18649 assert_eq!(nearby.len(), 1);
18650 assert_eq!(nearby[0].rows.len(), 1);
18651 assert!(nearby[0].rows[0].is_chest_shell);
18652 }
18653
18654 #[test]
18655 fn chest_pickup_destinations_offer_person_and_worn_bag() {
18656 let mut state = sample_state();
18657 let back_id = uuid::Uuid::from_u128(42);
18658 state.worn.insert(
18659 BodySlot::Back,
18660 flatland_protocol::ItemStack {
18661 template_id: "travel_backpack".into(),
18662 quantity: 1,
18663 item_instance_id: Some(back_id),
18664 props: Default::default(),
18665 status_bindings: Vec::new(),
18666 contents: Vec::new(),
18667 display_name: Some("Travel Backpack".into()),
18668 category: Some("container".into()),
18669 base_mass: Some(2.5),
18670 base_volume: Some(12.0),
18671 capacity_volume: Some(80.0),
18672 stackable: Some(false),
18673 world_placeable: Some(false),
18674 worker_lodging_capacity: None,
18675 equip_slot: None,
18676 armor_physical: None,
18677 resists: vec![],
18678 hand_slots: None,
18679 listable: None,
18680 ..Default::default()
18681 },
18682 );
18683 let opts = state.chest_pickup_destinations("chest-1");
18684 assert!(matches!(
18685 opts.first().map(|o| &o.kind),
18686 Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
18687 ));
18688 assert!(opts.iter().any(|o| matches!(
18689 &o.kind,
18690 MoveOptionKind::PickupPlaced {
18691 nest_parent_instance_id: None,
18692 ..
18693 }
18694 )));
18695 assert!(opts.iter().any(|o| matches!(
18696 &o.kind,
18697 MoveOptionKind::PickupPlaced {
18698 nest_parent_instance_id: Some(id),
18699 ..
18700 } if *id == back_id
18701 )));
18702 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
18703 }
18704
18705 #[test]
18706 fn placed_container_public_label_hides_owner_custom_name() {
18707 let owner = uuid::Uuid::from_u128(99);
18708 let mut state = sample_state();
18709 state.character_id = Some(uuid::Uuid::from_u128(1));
18710 state.inventory_hints.insert(
18711 "wooden_chest_medium".into(),
18712 InventoryHint {
18713 display_name: "Medium Wooden Chest".into(),
18714 category: "container".into(),
18715 base_mass: None,
18716 base_volume: None,
18717 capacity_volume: None,
18718 stackable: false,
18719 listable: true,
18720 base_value_copper: None,
18721 },
18722 );
18723 let chest = flatland_protocol::PlacedContainerView {
18724 id: "c1".into(),
18725 template_id: "wooden_chest_medium".into(),
18726 display_name: "Barry's Loot #a3f2".into(),
18727 x: 128.0,
18728 y: 128.0,
18729 z: 0.0,
18730 locked: false,
18731 accessible: true,
18732 owner_character_id: Some(owner),
18733 contents: vec![],
18734 lock_id: None,
18735 capacity_volume: None,
18736 item_instance_id: None,
18737 tile_id: None,
18738 worker_lodging_capacity: None,
18739 blocking: false,
18740 blocking_radius_m: 0.0,
18741 building_id: None,
18742 };
18743 assert_eq!(
18744 state.placed_container_public_label(&chest),
18745 "Medium Wooden Chest"
18746 );
18747 state.character_id = Some(owner);
18748 assert_eq!(
18749 state.placed_container_public_label(&chest),
18750 "Barry's Loot #a3f2"
18751 );
18752 }
18753
18754 #[test]
18755 fn location_context_shows_crop_growth_percent_not_depleted() {
18756 let mut state = sample_state();
18757 state.player = state.entities.first().cloned();
18758 state.resource_nodes[0].label = "Carrot (growing)".into();
18759 state.resource_nodes[0].x = 128.2;
18760 state.resource_nodes[0].y = 128.0;
18761 state.resource_nodes[0].state = ResourceNodeState::Cooldown;
18762 state.resource_nodes[0].growth_progress = Some(0.47);
18763 let lines = state.location_context_lines();
18764 let line = lines
18765 .iter()
18766 .find(|l| l.text.contains("Carrot"))
18767 .map(|l| l.text.as_str())
18768 .unwrap_or("");
18769 assert!(
18770 line.contains("(growing, 47%)"),
18771 "expected growth percent, got: {line}"
18772 );
18773 assert!(
18774 !line.contains("depleted"),
18775 "growing crop should not show depleted: {line}"
18776 );
18777 }
18778
18779 #[test]
18780 fn resource_node_near_action_suffix_prefers_growth() {
18781 let node = ResourceNodeView {
18782 id: "crop".into(),
18783 label: "Wheat".into(),
18784 x: 0.0,
18785 y: 0.0,
18786 z: 0.0,
18787 item_template: "wheat".into(),
18788 state: ResourceNodeState::Cooldown,
18789 blocking: false,
18790 blocking_radius_m: 0.0,
18791 harvest_off: false,
18792 tile_id: None,
18793 yaw: 0.0,
18794 pitch: 0.0,
18795 roll: 0.0,
18796 draw_scale: 1.0,
18797 sprite_mode: None,
18798 growth_progress: Some(0.12),
18799 presentation_state: None,
18800 channel_start_tick: None,
18801 channel_end_tick: None,
18802 harvest_drop_templates: vec![],
18803 };
18804 assert_eq!(resource_node_near_action_suffix(&node), " (growing, 12%)");
18805 }
18806
18807 #[test]
18808 fn location_context_lists_nearby_resource_node() {
18809 let mut state = sample_state();
18810 state.player = state.entities.first().cloned();
18811 state.resource_nodes[0].x = 128.2;
18812 state.resource_nodes[0].y = 128.0;
18813 let lines = state.location_context_lines();
18814 assert!(
18815 lines
18816 .iter()
18817 .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
18818 "expected resource node in context: {:?}",
18819 lines
18820 );
18821 }
18822
18823 #[test]
18824 fn quest_board_usable_within_board_radius() {
18825 let mut state = sample_state();
18826 state.player = state.entities.first().cloned();
18827 state.interactables = vec![flatland_protocol::InteractableView {
18828 id: "board-1".into(),
18829 kind: "quest_board".into(),
18830 label: "Town Quest Board".into(),
18831 x: 130.5,
18832 y: 128.0,
18833 z: 0.0,
18834 board_id: Some("starter_town_board".into()),
18835 }];
18836 assert_eq!(
18838 state.nearest_interact_target().as_deref(),
18839 Some("board-1"),
18840 "quest board should be selectable at ~2.5m"
18841 );
18842 let lines = state.location_context_lines();
18843 assert!(
18844 lines
18845 .iter()
18846 .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
18847 "HUD should advertise f when board is in range: {:?}",
18848 lines
18849 );
18850 }
18851
18852 #[test]
18853 fn quest_board_keeps_multiple_offers() {
18854 let mut state = sample_state();
18855 let offer = |id: &str, title: &str| flatland_protocol::QuestOffer {
18856 quest_id: id.into(),
18857 title: title.into(),
18858 description: format!("{title} desc"),
18859 step_count: 2,
18860 };
18861 state.push_quest_offer(offer("ada_goblin_hunt", "Goblin Trouble"));
18862 state.push_quest_offer(offer("daily_20695_1", "Town board: Deer threat"));
18863 state.push_quest_offer(offer("ada_goblin_hunt", "Goblin Trouble"));
18864 assert_eq!(state.pending_quest_offers.len(), 2);
18865 assert_eq!(
18866 state.selected_quest_offer().unwrap().quest_id,
18867 "ada_goblin_hunt"
18868 );
18869 state.move_quest_offer_selection(1);
18870 assert_eq!(
18871 state.selected_quest_offer().unwrap().quest_id,
18872 "daily_20695_1"
18873 );
18874 state.remove_quest_offer("daily_20695_1");
18875 assert_eq!(state.pending_quest_offers.len(), 1);
18876 assert!(state.show_quest_offer);
18877 state.remove_quest_offer("ada_goblin_hunt");
18878 assert!(!state.show_quest_offer);
18879 assert!(state.pending_quest_offers.is_empty());
18880 }
18881
18882 #[test]
18883 fn inventory_selectable_rows_excludes_equipped_shells_but_keeps_bag_contents() {
18884 let mut state = sample_state();
18885 state.worn.insert(
18886 BodySlot::Back,
18887 flatland_protocol::ItemStack {
18888 template_id: "travel_backpack".into(),
18889 quantity: 1,
18890 item_instance_id: Some(uuid::Uuid::from_u128(3)),
18891 props: Default::default(),
18892 status_bindings: Vec::new(),
18893 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
18894 display_name: None,
18895 category: None,
18896 base_mass: None,
18897 base_volume: None,
18898 capacity_volume: None,
18899 stackable: None,
18900 world_placeable: None,
18901 worker_lodging_capacity: None,
18902 equip_slot: None,
18903 armor_physical: None,
18904 resists: vec![],
18905 hand_slots: None,
18906 listable: None,
18907 ..Default::default()
18908 },
18909 );
18910 state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
18911 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18912 id: "chest-1".into(),
18913 template_id: "wooden_chest_small".into(),
18914 display_name: "Wooden Chest".into(),
18915 x: 129.0,
18916 y: 128.0,
18917 z: 0.0,
18918 locked: false,
18919 accessible: true,
18920 owner_character_id: None,
18921 contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
18922 lock_id: None,
18923 capacity_volume: None,
18924 item_instance_id: Some(uuid::Uuid::from_u128(4)),
18925 tile_id: None,
18926 worker_lodging_capacity: None,
18927 blocking: false,
18928 blocking_radius_m: 0.0,
18929 building_id: None,
18930 }];
18931
18932 state.inventory_tab = InventoryTab::OnPerson;
18933 let rows = state.inventory_selectable_rows();
18934 let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
18935 assert_eq!(
18936 sections,
18937 vec![
18938 InventorySection::Person, InventorySection::Person, ]
18941 );
18942 assert_eq!(rows[0].stack.template_id, "iron_ore");
18943 assert_eq!(rows[0].depth, 0);
18944 assert!(!rows[0].is_equip_shell);
18945 assert_eq!(rows[1].stack.template_id, "lumber");
18946
18947 let lines = state.inventory_browser_lines();
18948 assert!(lines.iter().any(|l| matches!(
18949 l,
18950 InventoryBrowserLine::Section(s) if s.contains("carried bags")
18951 )));
18952 assert!(lines.iter().any(|l| matches!(
18953 l,
18954 InventoryBrowserLine::Item { text, .. } if text.contains("iron_ore")
18955 )));
18956 assert!(!lines.iter().any(|l| matches!(
18957 l,
18958 InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
18959 )));
18960 assert!(!lines.iter().any(|l| matches!(
18961 l,
18962 InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
18963 )));
18964
18965 state.inventory_tab = InventoryTab::Nearby;
18966 let nearby_rows = state.inventory_selectable_rows();
18967 assert_eq!(nearby_rows.len(), 2);
18968 assert!(nearby_rows[0].is_chest_shell);
18969 assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
18970 let nearby_lines = state.inventory_browser_lines();
18971 assert!(nearby_lines.iter().any(|l| matches!(
18972 l,
18973 InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
18974 )));
18975 }
18976
18977 #[test]
18978 fn give_worker_notice_does_not_put_item_back_in_bag() {
18979 let mut state = sample_state();
18980 let id = uuid::Uuid::from_u128(42);
18981 let mut saw = flatland_protocol::ItemStack::simple("handsaw", 1);
18982 saw.item_instance_id = Some(id);
18983 saw.display_name = Some("Handsaw".into());
18984 state.sync_inventory_from_stacks(&[saw]);
18985 assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 1);
18986
18987 state.remove_carried_instance(id, None);
18988 assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 0);
18989 assert!(state.inventory_stacks.is_empty());
18990
18991 state.apply_interaction_notice(&flatland_protocol::InteractionNotice {
18992 target_id: "worker-1".into(),
18993 message: "Gave 1x Handsaw to Laborer".into(),
18994 coins_delta: 0,
18995 inventory_delta: vec![flatland_protocol::ItemStack::simple("handsaw", 1)],
18996 });
18997 assert_eq!(
18998 state.inventory.get("handsaw").copied().unwrap_or(0),
18999 0,
19000 "Gave notice must not restore the handed stack"
19001 );
19002 }
19003
19004 #[test]
19005 fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
19006 let mut state = sample_state();
19007 let back_id = uuid::Uuid::from_u128(5);
19008 state.worn.insert(
19009 BodySlot::Back,
19010 flatland_protocol::ItemStack {
19011 template_id: "travel_backpack".into(),
19012 quantity: 1,
19013 item_instance_id: Some(back_id),
19014 props: Default::default(),
19015 status_bindings: Vec::new(),
19016 contents: Vec::new(),
19017 display_name: None,
19018 category: Some("container".into()),
19019 base_mass: None,
19020 base_volume: None,
19021 capacity_volume: Some(80.0),
19022 stackable: None,
19023 world_placeable: None,
19024 worker_lodging_capacity: None,
19025 equip_slot: None,
19026 armor_physical: None,
19027 resists: vec![],
19028 hand_slots: None,
19029 listable: None,
19030 ..Default::default()
19031 },
19032 );
19033 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
19034 id: "chest-1".into(),
19035 template_id: "wooden_chest_small".into(),
19036 display_name: "Wooden Chest".into(),
19037 x: 129.0,
19038 y: 128.0,
19039 z: 0.0,
19040 locked: false,
19041 accessible: true,
19042 owner_character_id: None,
19043 contents: Vec::new(),
19044 lock_id: None,
19045 capacity_volume: None,
19046 item_instance_id: Some(uuid::Uuid::from_u128(6)),
19047 tile_id: None,
19048 worker_lodging_capacity: None,
19049 blocking: false,
19050 blocking_radius_m: 0.0,
19051 building_id: None,
19052 }];
19053
19054 let opts = state.move_destinations_for(
19057 &flatland_protocol::InventoryLocation::Root,
19058 None,
19059 None,
19060 "lumber",
19061 );
19062 assert!(!opts.iter().any(|o| matches!(
19063 &o.kind,
19064 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
19065 )));
19066 assert!(opts.iter().any(|o| matches!(
19067 &o.kind,
19068 MoveOptionKind::Move { location, parent_instance_id, .. }
19069 if *location == flatland_protocol::InventoryLocation::Worn {
19070 slot: BodySlot::Back,
19071 } && *parent_instance_id == Some(back_id)
19072 )));
19073 assert!(opts.iter().any(|o| matches!(
19074 &o.kind,
19075 MoveOptionKind::Move { location, .. }
19076 if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
19077 )));
19078 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
19079 assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
19080 let backpack = opts
19081 .iter()
19082 .find(|o| {
19083 matches!(
19084 &o.kind,
19085 MoveOptionKind::Move {
19086 location: flatland_protocol::InventoryLocation::Worn {
19087 slot: BodySlot::Back,
19088 },
19089 parent_instance_id,
19090 } if *parent_instance_id == Some(back_id)
19091 )
19092 })
19093 .expect("worn backpack destination");
19094 assert_eq!(backpack.volume, Some((0.0, 80.0)));
19095 assert_eq!(
19096 backpack.volume_usage_label().as_deref(),
19097 Some("vol 0/80 (80 free)")
19098 );
19099
19100 let from_backpack = flatland_protocol::InventoryLocation::Worn {
19104 slot: BodySlot::Back,
19105 };
19106 let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
19107 assert!(!opts.iter().any(|o| matches!(
19108 &o.kind,
19109 MoveOptionKind::Move { location, parent_instance_id, .. }
19110 if *location == from_backpack && *parent_instance_id == Some(back_id)
19111 )));
19112 assert!(opts.iter().any(|o| matches!(
19113 &o.kind,
19114 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
19115 )));
19116 }
19117
19118 #[test]
19119 fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
19120 let mut state = sample_state();
19121 state.worn.insert(
19124 BodySlot::Waist,
19125 flatland_protocol::ItemStack {
19126 template_id: "simple_belt".into(),
19127 quantity: 1,
19128 item_instance_id: Some(uuid::Uuid::from_u128(10)),
19129 props: Default::default(),
19130 status_bindings: Vec::new(),
19131 contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
19132 display_name: None,
19133 category: Some("container".into()),
19134 base_mass: None,
19135 base_volume: None,
19136 capacity_volume: None,
19137 stackable: None,
19138 world_placeable: None,
19139 worker_lodging_capacity: None,
19140 equip_slot: None,
19141 armor_physical: None,
19142 resists: vec![],
19143 hand_slots: None,
19144 listable: None,
19145 ..Default::default()
19146 },
19147 );
19148 state.worn.insert(
19149 BodySlot::Head,
19150 flatland_protocol::ItemStack {
19151 template_id: "cloth_cap".into(),
19152 quantity: 1,
19153 item_instance_id: Some(uuid::Uuid::from_u128(11)),
19154 props: Default::default(),
19155 status_bindings: Vec::new(),
19156 contents: Vec::new(),
19157 display_name: None,
19158 category: Some("armor".into()),
19159 base_mass: None,
19160 base_volume: None,
19161 capacity_volume: None,
19162 stackable: None,
19163 world_placeable: None,
19164 worker_lodging_capacity: None,
19165 equip_slot: None,
19166 armor_physical: None,
19167 resists: vec![],
19168 hand_slots: None,
19169 listable: None,
19170 ..Default::default()
19171 },
19172 );
19173 state.worn.insert(
19174 BodySlot::Back,
19175 flatland_protocol::ItemStack {
19176 template_id: "travel_backpack".into(),
19177 quantity: 1,
19178 item_instance_id: Some(uuid::Uuid::from_u128(12)),
19179 props: Default::default(),
19180 status_bindings: Vec::new(),
19181 contents: Vec::new(),
19182 display_name: None,
19183 category: Some("container".into()),
19184 base_mass: None,
19185 base_volume: None,
19186 capacity_volume: None,
19187 stackable: None,
19188 world_placeable: None,
19189 worker_lodging_capacity: None,
19190 equip_slot: None,
19191 armor_physical: None,
19192 resists: vec![],
19193 hand_slots: None,
19194 listable: None,
19195 ..Default::default()
19196 },
19197 );
19198
19199 let rows = state.worn_rows();
19200 assert_eq!(rows.len(), 4);
19202 assert_eq!(rows[0].stack.template_id, "cloth_cap");
19203 assert!(rows[0].is_equip_shell);
19204 assert_eq!(rows[1].stack.template_id, "travel_backpack");
19205 assert!(rows[1].is_equip_shell);
19206 assert_eq!(rows[2].stack.template_id, "simple_belt");
19207 assert!(rows[2].is_equip_shell);
19208 assert_eq!(rows[3].stack.template_id, "leather_pouch");
19209 assert_eq!(rows[3].depth, 1);
19210 assert!(!rows[3].is_equip_shell);
19211 }
19212
19213 #[test]
19214 fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
19215 let mut state = sample_state();
19216 state.worn.insert(
19217 BodySlot::Waist,
19218 flatland_protocol::ItemStack {
19219 template_id: "simple_belt".into(),
19220 quantity: 1,
19221 item_instance_id: Some(uuid::Uuid::from_u128(20)),
19222 props: Default::default(),
19223 status_bindings: Vec::new(),
19224 contents: Vec::new(),
19225 display_name: Some("Simple Belt".into()),
19226 category: Some("container".into()),
19227 base_mass: None,
19228 base_volume: None,
19229 capacity_volume: None,
19230 stackable: None,
19231 world_placeable: None,
19232 worker_lodging_capacity: None,
19233 equip_slot: None,
19234 armor_physical: None,
19235 resists: vec![],
19236 hand_slots: None,
19237 listable: None,
19238 ..Default::default()
19239 },
19240 );
19241 state.worn.insert(
19242 BodySlot::Head,
19243 flatland_protocol::ItemStack {
19244 template_id: "cloth_cap".into(),
19245 quantity: 1,
19246 item_instance_id: Some(uuid::Uuid::from_u128(21)),
19247 props: Default::default(),
19248 status_bindings: Vec::new(),
19249 contents: Vec::new(),
19250 display_name: Some("Cloth Cap".into()),
19251 category: Some("armor".into()),
19252 base_mass: None,
19253 base_volume: None,
19254 capacity_volume: None,
19255 stackable: None,
19256 world_placeable: None,
19257 worker_lodging_capacity: None,
19258 equip_slot: None,
19259 armor_physical: None,
19260 resists: vec![],
19261 hand_slots: None,
19262 listable: None,
19263 ..Default::default()
19264 },
19265 );
19266
19267 let opts = state.move_destinations_for(
19268 &flatland_protocol::InventoryLocation::Root,
19269 None,
19270 None,
19271 "leather_pouch",
19272 );
19273 assert!(
19274 opts.iter().any(|o| matches!(
19275 &o.kind,
19276 MoveOptionKind::Move { location, .. }
19277 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
19278 )),
19279 "belt loop must be offered when moving a pouch"
19280 );
19281 assert!(
19282 !opts.iter().any(|o| matches!(
19283 &o.kind,
19284 MoveOptionKind::Move { location, .. }
19285 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
19286 )),
19287 "armor slots can't hold other items and must not appear as move destinations"
19288 );
19289 let belt_opt = opts
19290 .iter()
19291 .find(|o| matches!(
19292 &o.kind,
19293 MoveOptionKind::Move { location, .. }
19294 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
19295 ))
19296 .unwrap();
19297 assert!(belt_opt.label.contains("belt loop"));
19298
19299 let opts = state.move_destinations_for(
19300 &flatland_protocol::InventoryLocation::Root,
19301 None,
19302 None,
19303 "lumber",
19304 );
19305 assert!(
19306 !opts.iter().any(|o| o.label.contains("belt loop")),
19307 "loose materials must not target the belt shell — only nested pouches"
19308 );
19309 }
19310
19311 #[test]
19312 fn move_destinations_for_offers_dimensional_pouch_on_belt() {
19313 let mut state = sample_state();
19314 let belt_id = uuid::Uuid::from_u128(30);
19315 let pouch_id = uuid::Uuid::from_u128(31);
19316 state.worn.insert(
19317 BodySlot::Waist,
19318 flatland_protocol::ItemStack {
19319 template_id: "simple_belt".into(),
19320 quantity: 1,
19321 item_instance_id: Some(belt_id),
19322 props: Default::default(),
19323 status_bindings: Vec::new(),
19324 world_placeable: None,
19325 worker_lodging_capacity: None,
19326 equip_slot: None,
19327 armor_physical: None,
19328 resists: vec![],
19329 hand_slots: None,
19330 contents: vec![flatland_protocol::ItemStack {
19331 template_id: "dimensional_pouch".into(),
19332 quantity: 1,
19333 item_instance_id: Some(pouch_id),
19334 props: Default::default(),
19335 status_bindings: Vec::new(),
19336 contents: Vec::new(),
19337 display_name: Some("Dimensional Pouch".into()),
19338 category: Some("container".into()),
19339 base_mass: None,
19340 base_volume: None,
19341 capacity_volume: Some(200.0),
19342 stackable: None,
19343 world_placeable: None,
19344 worker_lodging_capacity: None,
19345 equip_slot: None,
19346 armor_physical: None,
19347 resists: vec![],
19348 hand_slots: None,
19349 listable: None,
19350 ..Default::default()
19351 }],
19352 display_name: Some("Simple Belt".into()),
19353 category: Some("container".into()),
19354 base_mass: None,
19355 base_volume: None,
19356 capacity_volume: None,
19357 stackable: None,
19358 listable: None,
19359 ..Default::default()
19360 },
19361 );
19362
19363 let opts = state.move_destinations_for(
19364 &flatland_protocol::InventoryLocation::Root,
19365 None,
19366 None,
19367 "iron_ore",
19368 );
19369 assert!(
19370 opts.iter().any(|o| matches!(
19371 &o.kind,
19372 MoveOptionKind::Move {
19373 location,
19374 parent_instance_id,
19375 ..
19376 } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
19377 && *parent_instance_id == Some(pouch_id)
19378 )),
19379 "dimensional pouch clipped on belt must accept loose items"
19380 );
19381 assert!(
19382 opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
19383 "destination label should name the pouch"
19384 );
19385 }
19386
19387 #[test]
19388 fn container_volume_label_on_placed_chest_shell() {
19389 let mut state = sample_state();
19390 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
19391 id: "chest-1".into(),
19392 template_id: "wooden_chest_small".into(),
19393 display_name: "Camp Chest".into(),
19394 x: 129.0,
19395 y: 128.0,
19396 z: 0.0,
19397 locked: false,
19398 accessible: true,
19399 owner_character_id: None,
19400 contents: vec![flatland_protocol::ItemStack {
19401 template_id: "iron_ore".into(),
19402 quantity: 2,
19403 item_instance_id: None,
19404 props: Default::default(),
19405 status_bindings: Vec::new(),
19406 contents: Vec::new(),
19407 display_name: None,
19408 category: None,
19409 base_mass: None,
19410 base_volume: Some(2.0),
19411 capacity_volume: None,
19412 stackable: None,
19413 world_placeable: None,
19414 worker_lodging_capacity: None,
19415 equip_slot: None,
19416 armor_physical: None,
19417 resists: vec![],
19418 hand_slots: None,
19419 listable: None,
19420 ..Default::default()
19421 }],
19422 lock_id: None,
19423 capacity_volume: Some(60.0),
19424 item_instance_id: Some(uuid::Uuid::from_u128(4)),
19425 tile_id: None,
19426 worker_lodging_capacity: None,
19427 blocking: false,
19428 blocking_radius_m: 0.0,
19429 building_id: None,
19430 }];
19431 let nearby = state.nearby_containers();
19432 let label = state.container_volume_label(&nearby[0].rows[0]);
19433 assert!(
19434 label.contains("vol 4/60"),
19435 "expected used/cap in label, got {label}"
19436 );
19437 assert!(
19438 label.contains("56 free"),
19439 "expected free space, got {label}"
19440 );
19441 }
19442
19443 #[test]
19444 fn move_destinations_for_include_placed_chest_volume() {
19445 let mut state = sample_state();
19446 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
19447 id: "chest-1".into(),
19448 template_id: "wooden_chest_small".into(),
19449 display_name: "Camp Chest".into(),
19450 x: 129.0,
19451 y: 128.0,
19452 z: 0.0,
19453 locked: false,
19454 accessible: true,
19455 owner_character_id: None,
19456 contents: vec![flatland_protocol::ItemStack {
19457 template_id: "iron_ore".into(),
19458 quantity: 2,
19459 item_instance_id: None,
19460 props: Default::default(),
19461 status_bindings: Vec::new(),
19462 contents: Vec::new(),
19463 display_name: None,
19464 category: None,
19465 base_mass: None,
19466 base_volume: Some(2.0),
19467 capacity_volume: None,
19468 stackable: None,
19469 world_placeable: None,
19470 worker_lodging_capacity: None,
19471 equip_slot: None,
19472 armor_physical: None,
19473 resists: vec![],
19474 hand_slots: None,
19475 listable: None,
19476 ..Default::default()
19477 }],
19478 lock_id: None,
19479 capacity_volume: Some(60.0),
19480 item_instance_id: Some(uuid::Uuid::from_u128(4)),
19481 tile_id: None,
19482 worker_lodging_capacity: None,
19483 blocking: false,
19484 blocking_radius_m: 0.0,
19485 building_id: None,
19486 }];
19487 let opts = state.move_destinations_for(
19488 &flatland_protocol::InventoryLocation::Root,
19489 None,
19490 None,
19491 "lumber",
19492 );
19493 let chest = opts
19494 .iter()
19495 .find(|o| {
19496 matches!(
19497 &o.kind,
19498 MoveOptionKind::Move {
19499 location: flatland_protocol::InventoryLocation::Placed { container_id },
19500 ..
19501 } if container_id == "chest-1"
19502 )
19503 })
19504 .expect("nearby chest destination");
19505 assert_eq!(chest.volume, Some((4.0, 60.0)));
19506 assert_eq!(
19507 chest.volume_usage_label().as_deref(),
19508 Some("vol 4/60 (56 free)")
19509 );
19510 assert!(opts
19511 .iter()
19512 .filter(|o| matches!(o.kind, MoveOptionKind::Drop | MoveOptionKind::Cancel))
19513 .all(|o| o.volume.is_none()));
19514 }
19515
19516 #[test]
19517 fn chest_pickup_destinations_include_worn_bag_volume() {
19518 let mut state = sample_state();
19519 let back_id = uuid::Uuid::from_u128(5);
19520 state.worn.insert(
19521 BodySlot::Back,
19522 flatland_protocol::ItemStack {
19523 template_id: "travel_backpack".into(),
19524 quantity: 1,
19525 item_instance_id: Some(back_id),
19526 props: Default::default(),
19527 status_bindings: Vec::new(),
19528 contents: Vec::new(),
19529 display_name: Some("Travel Backpack".into()),
19530 category: Some("container".into()),
19531 capacity_volume: Some(80.0),
19532 ..Default::default()
19533 },
19534 );
19535 let opts = state.chest_pickup_destinations("chest-1");
19536 let bag = opts
19537 .iter()
19538 .find(|o| {
19539 matches!(
19540 &o.kind,
19541 MoveOptionKind::PickupPlaced {
19542 nest_parent_instance_id,
19543 ..
19544 } if *nest_parent_instance_id == Some(back_id)
19545 )
19546 })
19547 .expect("pickup into worn backpack");
19548 assert_eq!(bag.volume, Some((0.0, 80.0)));
19549 assert!(opts
19550 .iter()
19551 .filter(|o| matches!(
19552 o.kind,
19553 MoveOptionKind::RelocatePlaced { .. } | MoveOptionKind::Cancel
19554 ))
19555 .all(|o| o.volume.is_none()));
19556 }
19557
19558 #[test]
19559 fn key_pair_chest_label_from_placed_lock_id() {
19560 let mut state = sample_state();
19561 let owner = uuid::Uuid::from_u128(77);
19562 state.character_id = Some(owner);
19563 let lock = uuid::Uuid::from_u128(99).to_string();
19564 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
19565 id: "chest-1".into(),
19566 template_id: "wooden_chest_small".into(),
19567 display_name: "Barry's Loot #a3f2".into(),
19568 x: 129.0,
19569 y: 128.0,
19570 z: 0.0,
19571 locked: true,
19572 accessible: true,
19573 owner_character_id: Some(owner),
19574 contents: Vec::new(),
19575 lock_id: Some(lock.clone()),
19576 capacity_volume: None,
19577 item_instance_id: Some(uuid::Uuid::from_u128(4)),
19578 tile_id: None,
19579 worker_lodging_capacity: None,
19580 blocking: false,
19581 blocking_radius_m: 0.0,
19582 building_id: None,
19583 }];
19584 let key_id = uuid::Uuid::from_u128(5);
19585 let key = flatland_protocol::ItemStack {
19586 template_id: KEY_TEMPLATE.into(),
19587 quantity: 1,
19588 item_instance_id: Some(key_id),
19589 props: BTreeMap::from([
19590 (PROP_OPENS_LOCK_ID.into(), lock),
19591 (
19592 PROP_OPENS_CONTAINER_NAME.into(),
19593 "Barry's Loot #a3f2".into(),
19594 ),
19595 ]),
19596 status_bindings: Vec::new(),
19597 contents: Vec::new(),
19598 display_name: Some("Container Key".into()),
19599 category: Some("key".into()),
19600 base_mass: None,
19601 base_volume: None,
19602 capacity_volume: None,
19603 stackable: None,
19604 world_placeable: None,
19605 worker_lodging_capacity: None,
19606 equip_slot: None,
19607 armor_physical: None,
19608 resists: vec![],
19609 hand_slots: None,
19610 listable: None,
19611 ..Default::default()
19612 };
19613 state.inventory_stacks = vec![key.clone()];
19614 assert_eq!(
19615 state.key_pair_chest_label(&key).as_deref(),
19616 Some("Barry's Loot #a3f2")
19617 );
19618 assert!(state.key_drop_blocked(&key));
19619 }
19620
19621 #[test]
19622 fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
19623 let mut state = sample_state();
19624 let lock = uuid::Uuid::from_u128(101).to_string();
19625 let key = flatland_protocol::ItemStack {
19626 template_id: KEY_TEMPLATE.into(),
19627 quantity: 1,
19628 item_instance_id: Some(uuid::Uuid::from_u128(7)),
19629 props: BTreeMap::from([
19630 (PROP_OPENS_LOCK_ID.into(), lock),
19631 (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
19632 ]),
19633 status_bindings: Vec::new(),
19634 contents: Vec::new(),
19635 display_name: None,
19636 category: Some("key".into()),
19637 base_mass: None,
19638 base_volume: None,
19639 capacity_volume: None,
19640 stackable: None,
19641 world_placeable: None,
19642 worker_lodging_capacity: None,
19643 equip_slot: None,
19644 armor_physical: None,
19645 resists: vec![],
19646 hand_slots: None,
19647 listable: None,
19648 ..Default::default()
19649 };
19650 state.placed_containers.clear();
19651 assert_eq!(
19652 state.key_pair_chest_label(&key).as_deref(),
19653 Some("Camp Stash")
19654 );
19655 }
19656
19657 #[test]
19658 fn key_drop_allowed_when_paired_chest_unlocked() {
19659 let mut state = sample_state();
19660 let lock = uuid::Uuid::from_u128(100).to_string();
19661 let key_id = uuid::Uuid::from_u128(6);
19662 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
19663 id: "chest-1".into(),
19664 template_id: "wooden_chest_small".into(),
19665 display_name: "Camp Chest".into(),
19666 x: 129.0,
19667 y: 128.0,
19668 z: 0.0,
19669 locked: false,
19670 accessible: true,
19671 owner_character_id: None,
19672 contents: Vec::new(),
19673 lock_id: Some(lock.clone()),
19674 capacity_volume: None,
19675 item_instance_id: None,
19676 tile_id: None,
19677 worker_lodging_capacity: None,
19678 blocking: false,
19679 blocking_radius_m: 0.0,
19680 building_id: None,
19681 }];
19682 let key = flatland_protocol::ItemStack {
19683 template_id: KEY_TEMPLATE.into(),
19684 quantity: 1,
19685 item_instance_id: Some(key_id),
19686 props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
19687 status_bindings: Vec::new(),
19688 contents: Vec::new(),
19689 display_name: None,
19690 category: Some("key".into()),
19691 base_mass: None,
19692 base_volume: None,
19693 capacity_volume: None,
19694 stackable: None,
19695 world_placeable: None,
19696 worker_lodging_capacity: None,
19697 equip_slot: None,
19698 armor_physical: None,
19699 resists: vec![],
19700 hand_slots: None,
19701 listable: None,
19702 ..Default::default()
19703 };
19704 state.inventory_stacks = vec![key.clone()];
19705 assert!(!state.key_drop_blocked(&key));
19706 let opts = state.move_destinations_for(
19707 &flatland_protocol::InventoryLocation::Root,
19708 None,
19709 Some(key_id),
19710 KEY_TEMPLATE,
19711 );
19712 assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
19713 }
19714
19715 #[test]
19716 fn combat_hud_refreshes_progression_xp_when_entity_stale() {
19717 use flatland_protocol::{CombatHud, ProgressionCurve, ProgressionXp};
19718
19719 let mut state = sample_state();
19720 let curve = ProgressionCurve::default();
19721 let bootstrap =
19722 ProgressionXp::bootstrap_new(curve.baseline_display, curve.xp_base, curve.xp_growth);
19723 let mut fresh = bootstrap.clone();
19724 fresh.strength += 0.08;
19725 if let Some(player) = state.player.as_mut() {
19726 player.progression_xp = Some(bootstrap);
19727 }
19728
19729 let combat = CombatHud {
19730 progression_xp: Some(fresh.clone()),
19731 progression_baseline: curve.baseline_display,
19732 progression_xp_base: curve.xp_base,
19733 progression_xp_growth: curve.xp_growth,
19734 attributes: state.player.as_ref().and_then(|p| p.attributes),
19735 skills: state.player.as_ref().and_then(|p| p.skills.clone()),
19736 ..CombatHud::default()
19737 };
19738 state.apply_combat_hud(&combat);
19739
19740 let xp = state
19741 .player
19742 .as_ref()
19743 .and_then(|p| p.progression_xp.as_ref())
19744 .expect("xp");
19745 assert!((xp.strength - fresh.strength).abs() < 0.001);
19746 assert!(state.progression_curve.is_some());
19747 }
19748
19749 #[test]
19750 fn combat_hud_syncs_known_abilities_and_hotbar() {
19751 use flatland_protocol::CombatHud;
19752
19753 let mut state = sample_state();
19754 let combat = CombatHud {
19755 known_abilities: vec!["unarmed".into(), "fireball".into()],
19756 hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
19757 max_abilities_per_rotation: 4,
19758 ability_id: "short_sword_slash".into(),
19759 ..CombatHud::default()
19760 };
19761 state.apply_combat_hud(&combat);
19762
19763 assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
19764 assert_eq!(state.hotbar_ability(1), Some("fireball"));
19765 assert_eq!(state.hotbar_ability(2), None);
19766 assert_eq!(state.hotbar_ability(3), Some("unarmed"));
19767 assert_eq!(state.max_abilities_per_rotation, 4);
19768 let choices = state.loadout_ability_choices();
19769 assert!(choices.iter().any(|a| a == "short_sword_slash"));
19770 assert!(choices.iter().any(|a| a == "fireball"));
19771 }
19772
19773 #[test]
19774 fn loadout_hotbar_choices_include_inventory_consumables() {
19775 let mut state = sample_state();
19776 state.known_abilities = vec!["unarmed".into()];
19777 state.weapon_ability_id = "unarmed".into();
19778 state.inventory_stacks = vec![flatland_protocol::ItemStack {
19779 template_id: "empty_bottle".into(),
19780 quantity: 1,
19781 item_instance_id: Some(uuid::Uuid::from_u128(9)),
19782 display_name: Some("Glass Bottle of Water".into()),
19783 category: Some("container".into()),
19784 props: [
19785 ("serving".into(), "1".into()),
19786 ("liquid_vessel".into(), "1".into()),
19787 ("serving_holds".into(), "liquid".into()),
19788 ]
19789 .into_iter()
19790 .collect(),
19791 ..Default::default()
19792 }];
19793 state.inventory.insert("empty_bottle".into(), 1);
19794 state.inventory_hints.insert(
19795 "empty_bottle".into(),
19796 InventoryHint {
19797 display_name: "Glass Bottle".into(),
19798 category: "container".into(),
19799 ..Default::default()
19800 },
19801 );
19802
19803 let choices = state.loadout_hotbar_choices();
19804 assert!(choices.iter().any(|c| c.binding == "unarmed"));
19805 let water = choices
19806 .iter()
19807 .find(|c| c.binding == "item:empty_bottle")
19808 .expect("serving bottle binding");
19809 assert_eq!(water.meta.as_deref(), Some("use"));
19810 assert!(water.label.contains("Glass Bottle of Water"));
19811 assert_eq!(state.hotbar_slot_label(1), None, "unbound until set");
19812 state.hotbar = vec![None, None, None, None, Some("item:empty_bottle".into())];
19813 assert_eq!(
19814 state.hotbar_slot_label(5).as_deref(),
19815 Some("Glass Bottle×1")
19816 );
19817 }
19818
19819 #[test]
19820 fn loadout_hotbar_choices_exclude_blueprint_scrolls() {
19821 let mut state = sample_state();
19822 state.known_abilities = vec!["unarmed".into()];
19823 state.weapon_ability_id = "unarmed".into();
19824 state.inventory_stacks = vec![
19825 flatland_protocol::ItemStack {
19826 template_id: "carrot".into(),
19827 quantity: 2,
19828 display_name: Some("Wild Carrot".into()),
19829 category: Some("consumable".into()),
19830 ..Default::default()
19831 },
19832 flatland_protocol::ItemStack {
19833 template_id: "blueprint_dimensional_pouch".into(),
19834 quantity: 1,
19835 display_name: Some("Blueprint — Dimensional Pouch".into()),
19836 category: Some("consumable".into()),
19837 props: [("teaches_blueprint".into(), "craft_dimensional_pouch".into())]
19838 .into_iter()
19839 .collect(),
19840 ..Default::default()
19841 },
19842 ];
19843 state.inventory.insert("carrot".into(), 2);
19844 state
19845 .inventory
19846 .insert("blueprint_dimensional_pouch".into(), 1);
19847 state.inventory_hints.insert(
19848 "carrot".into(),
19849 InventoryHint {
19850 display_name: "Wild Carrot".into(),
19851 category: "consumable".into(),
19852 ..Default::default()
19853 },
19854 );
19855 state.inventory_hints.insert(
19856 "blueprint_dimensional_pouch".into(),
19857 InventoryHint {
19858 display_name: "Blueprint — Dimensional Pouch".into(),
19859 category: "consumable".into(),
19860 ..Default::default()
19861 },
19862 );
19863
19864 let choices = state.loadout_hotbar_choices();
19865 assert!(choices.iter().any(|c| c.binding == "item:carrot"));
19866 assert!(
19867 choices
19868 .iter()
19869 .all(|c| c.binding != "item:blueprint_dimensional_pouch"),
19870 "recipe scrolls must not appear on the hotbar picker: {choices:?}"
19871 );
19872 }
19873
19874 #[test]
19875 fn storage_store_options_excludes_hand_equipped() {
19876 let mut state = sample_state();
19877 let sword_id = uuid::Uuid::from_u128(11);
19878 let ore_id = uuid::Uuid::from_u128(22);
19879 state.inventory_stacks = vec![
19880 flatland_protocol::ItemStack {
19881 template_id: "short_sword".into(),
19882 quantity: 1,
19883 item_instance_id: Some(sword_id),
19884 display_name: Some("Short Sword".into()),
19885 category: Some("weapon".into()),
19886 ..Default::default()
19887 },
19888 flatland_protocol::ItemStack {
19889 template_id: "iron_ore".into(),
19890 quantity: 5,
19891 item_instance_id: Some(ore_id),
19892 display_name: Some("Iron Ore".into()),
19893 category: Some("resource".into()),
19894 ..Default::default()
19895 },
19896 ];
19897 state.mainhand_template_id = Some("short_sword".into());
19898 state.mainhand_instance_id = Some(sword_id);
19899
19900 let opts = state.storage_store_options();
19901 assert_eq!(opts.len(), 1);
19902 assert_eq!(opts[0].item_instance_id, ore_id);
19903 assert!(state.hand_equipped_instance_ids().contains(&sword_id));
19904 }
19905
19906 #[test]
19907 fn loose_consumable_move_picker_offers_use_and_storage() {
19908 let mut state = sample_state();
19909 let inst = uuid::Uuid::from_u128(77);
19910 state.inventory_stacks = vec![flatland_protocol::ItemStack {
19911 template_id: "carrot".into(),
19912 quantity: 2,
19913 item_instance_id: Some(inst),
19914 props: Default::default(),
19915 status_bindings: Vec::new(),
19916 contents: Vec::new(),
19917 display_name: Some("Wild Carrot".into()),
19918 category: Some("consumable".into()),
19919 base_mass: None,
19920 base_volume: None,
19921 capacity_volume: None,
19922 stackable: Some(true),
19923 world_placeable: None,
19924 worker_lodging_capacity: None,
19925 equip_slot: None,
19926 armor_physical: None,
19927 resists: vec![],
19928 hand_slots: None,
19929 listable: None,
19930 ..Default::default()
19931 }];
19932 state.inventory_hints.insert(
19933 "carrot".into(),
19934 InventoryHint {
19935 display_name: "Wild Carrot".into(),
19936 category: "consumable".into(),
19937 base_mass: Some(0.15),
19938 base_volume: Some(0.3),
19939 capacity_volume: None,
19940 stackable: true,
19941 listable: true,
19942 base_value_copper: None,
19943 },
19944 );
19945 state.show_inventory_menu = true;
19946 state.inventory_menu_index = 0;
19947
19948 let row = state.inventory_selected_row().expect("carrot row");
19949 let mut options = state.move_destinations_for(
19950 &row.from,
19951 row.from_parent_instance_id,
19952 row.stack.item_instance_id,
19953 &row.stack.template_id,
19954 );
19955 if row.from == flatland_protocol::InventoryLocation::Root
19956 && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
19957 {
19958 options.insert(
19959 0,
19960 MoveOption::action("Use (eat / drink)", MoveOptionKind::Use),
19961 );
19962 }
19963
19964 assert_eq!(
19965 options.first().map(|o| &o.label),
19966 Some(&"Use (eat / drink)".into())
19967 );
19968 assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
19969 assert!(options
19970 .iter()
19971 .any(|o| matches!(o.kind, MoveOptionKind::Drop)));
19972 }
19973
19974 #[test]
19975 fn inventory_category_group_order_is_stable() {
19976 assert_eq!(inventory_category_group("weapon").0, "Weapons");
19977 assert_eq!(inventory_category_group("armor").0, "Armor");
19978 assert_eq!(inventory_category_group("consumable").0, "Consumables");
19979 assert_eq!(inventory_category_group("liquid").0, "Consumables");
19980 assert_eq!(inventory_category_group("resource").0, "Resources");
19981 assert_eq!(inventory_category_group("container").0, "Containers");
19982 assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
19983 assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
19984 }
19985
19986 #[test]
19987 fn page_list_index_clamps_without_wrap() {
19988 assert_eq!(page_list_index(0, -1, 25), 0);
19989 assert_eq!(page_list_index(0, 1, 25), 10);
19990 assert_eq!(page_list_index(12, 1, 25), 22);
19991 assert_eq!(page_list_index(22, 1, 25), 24);
19992 assert_eq!(page_list_index(5, 1, 0), 0);
19993 assert_eq!(page_list_index(3, -1, 8), 0);
19994 }
19995
19996 #[test]
19997 fn inventory_filter_hides_non_matching_person_items() {
19998 let mut state = sample_state();
19999 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
20000 sword.display_name = Some("Iron Sword".into());
20001 sword.category = Some("weapon".into());
20002 let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
20003 herb.display_name = Some("Wild Herb".into());
20004 herb.category = Some("consumable".into());
20005 state.inventory_stacks = vec![sword, herb];
20006 state.inventory_tab = InventoryTab::OnPerson;
20007 state.inventory_filter = "sword".into();
20008
20009 let rows = state.inventory_selectable_rows();
20010 assert_eq!(rows.len(), 1);
20011 assert_eq!(rows[0].stack.template_id, "iron_sword");
20012
20013 let lines = state.inventory_browser_lines();
20014 assert!(lines.iter().any(|l| matches!(
20015 l,
20016 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
20017 )));
20018 assert!(!lines.iter().any(|l| matches!(
20019 l,
20020 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
20021 )));
20022 }
20023
20024 #[test]
20025 fn list_filter_chars_reject_mac_arrow_glyphs() {
20026 assert!(is_list_filter_char('a'));
20027 assert!(is_list_filter_char(' '));
20028 assert!(is_list_filter_char('-'));
20029 assert!(!is_list_filter_char('\u{F700}'));
20030 assert!(!is_list_filter_char('\u{F701}'));
20031 assert!(!is_list_filter_char('\n'));
20032 }
20033
20034 fn plank_blueprint() -> BlueprintView {
20035 BlueprintView {
20036 id: "plank".into(),
20037 label: "Plank".into(),
20038 craft_tier: 1,
20039 craft_ticks: 30,
20040 output: "wood_plank".into(),
20041 output_qty: 1,
20042 output_display_name: "Wood Plank".into(),
20043 station: None,
20044 category: None,
20045 inputs: vec![flatland_protocol::BlueprintIngredientView {
20046 template_id: "oak_log".into(),
20047 quantity: 1,
20048 consumed: true,
20049 display_name: "Oak Log".into(),
20050 }],
20051 required_tools: vec![],
20052 skill: None,
20053 failure_chance: 0.0,
20054 worker_train_copper: 0,
20055 }
20056 }
20057
20058 #[test]
20059 fn ready_tab_keeps_in_progress_craft_after_inputs_spent() {
20060 let mut state = sample_state();
20061 state.craft_tab = CraftTab::Ready;
20062 state.blueprints = vec![plank_blueprint()];
20063 state.inventory.clear();
20065 state.craft_channel_blueprint_id = Some("plank".into());
20066 state.timed_channel = Some(flatland_protocol::TimedChannelHud {
20067 label: "Crafting Plank".into(),
20068 channel: flatland_protocol::TimedChannelKind::Craft,
20069 ticks_remaining: 20,
20070 ticks_total: 30,
20071 ..Default::default()
20072 });
20073
20074 let idxs = state.craft_filtered_indices();
20075 assert_eq!(idxs, vec![0]);
20076 assert!(state.craft_blueprint_in_channel("plank"));
20077
20078 state.timed_channel = None;
20080 state.clear_craft_ready_pin();
20081 assert!(state.craft_filtered_indices().is_empty());
20082 }
20083
20084 #[test]
20085 fn ready_tab_keeps_last_batch_item_before_channel_hud_arrives() {
20086 let mut state = sample_state();
20087 state.craft_tab = CraftTab::Ready;
20088 state.blueprints = vec![plank_blueprint()];
20089 state.inventory.clear();
20090 state.craft_channel_blueprint_id = Some("plank".into());
20091 state.craft_channel_seen = false;
20092 state.timed_channel = None;
20093
20094 assert_eq!(state.craft_filtered_indices(), vec![0]);
20096 state.sync_craft_ready_pin();
20097 assert_eq!(state.craft_channel_blueprint_id.as_deref(), Some("plank"));
20098 assert_eq!(state.craft_filtered_indices(), vec![0]);
20099
20100 state.timed_channel = Some(flatland_protocol::TimedChannelHud {
20101 label: "Crafting Plank (3/3)".into(),
20102 channel: flatland_protocol::TimedChannelKind::Craft,
20103 ticks_remaining: 10,
20104 ticks_total: 30,
20105 ..Default::default()
20106 });
20107 state.sync_craft_ready_pin();
20108 assert!(state.craft_channel_seen);
20109 assert_eq!(state.craft_filtered_indices(), vec![0]);
20110
20111 state.timed_channel = None;
20113 state.sync_craft_ready_pin();
20114 assert!(state.craft_channel_blueprint_id.is_none());
20115 assert!(state.craft_filtered_indices().is_empty());
20116 }
20117
20118 #[test]
20119 fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
20120 let mut state = sample_state();
20121 let id_a = uuid::Uuid::from_u128(0xa1);
20122 let id_b = uuid::Uuid::from_u128(0xb2);
20123 let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
20124 sword_a.display_name = Some("Iron Sword".into());
20125 sword_a.category = Some("weapon".into());
20126 sword_a.item_instance_id = Some(id_a);
20127 let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
20128 sword_b.display_name = Some("Iron Sword".into());
20129 sword_b.category = Some("weapon".into());
20130 sword_b.item_instance_id = Some(id_b);
20131 state.inventory_stacks = vec![sword_a, sword_b];
20132 state.inventory_tab = InventoryTab::OnPerson;
20133
20134 let lines = state.inventory_browser_lines();
20135 let items: Vec<_> = lines
20136 .iter()
20137 .filter_map(|l| match l {
20138 InventoryBrowserLine::Item {
20139 title,
20140 instance_tooltip,
20141 ..
20142 } => Some((title.clone(), instance_tooltip.clone())),
20143 _ => None,
20144 })
20145 .collect();
20146 assert_eq!(items.len(), 2);
20147 for (title, tip) in &items {
20148 assert!(
20149 !title.contains('#'),
20150 "title should not show instance suffix: {title}"
20151 );
20152 assert!(
20153 tip.is_some(),
20154 "two identical rows should expose instance on hover"
20155 );
20156 }
20157
20158 state.inventory_stacks.pop();
20159 let lines = state.inventory_browser_lines();
20160 let one = lines.iter().find_map(|l| match l {
20161 InventoryBrowserLine::Item {
20162 title,
20163 instance_tooltip,
20164 ..
20165 } => Some((title.clone(), instance_tooltip.clone())),
20166 _ => None,
20167 });
20168 let (title, tip) = one.expect("one sword row");
20169 assert!(!title.contains('#'));
20170 assert!(tip.is_none(), "single row should not need instance tooltip");
20171 }
20172
20173 #[test]
20174 fn inventory_person_rows_group_by_category() {
20175 let mut state = sample_state();
20176 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
20177 sword.category = Some("weapon".into());
20178 sword.display_name = Some("Iron Sword".into());
20179 let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
20180 ore.category = Some("resource".into());
20181 ore.display_name = Some("Iron Ore".into());
20182 let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
20183 potion.category = Some("consumable".into());
20184 potion.display_name = Some("Health Potion".into());
20185 state.inventory_stacks = vec![ore, potion, sword];
20186 state.inventory_tab = InventoryTab::OnPerson;
20187
20188 let lines = state.inventory_browser_lines();
20189 let labels: Vec<&str> = lines
20190 .iter()
20191 .filter_map(|l| match l {
20192 InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
20193 _ => None,
20194 })
20195 .collect();
20196 assert!(
20197 labels.iter().any(|s| s.contains("Weapons")),
20198 "expected Weapons group: {labels:?}"
20199 );
20200 assert!(labels.iter().any(|s| s.contains("Consumables")));
20201 assert!(labels.iter().any(|s| s.contains("Resources")));
20202
20203 let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
20204 let consumable_pos = labels
20205 .iter()
20206 .position(|s| s.contains("Consumables"))
20207 .unwrap();
20208 let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
20209 assert!(weapon_pos < consumable_pos);
20210 assert!(consumable_pos < resource_pos);
20211 }
20212
20213 #[test]
20214 fn inventory_tab_cycle_resets_selection() {
20215 let mut state = sample_state();
20216 state.inventory_tab = InventoryTab::OnPerson;
20217 state.inventory_menu_index = 3;
20218 state.inventory_tab = state.inventory_tab.cycle(true);
20219 assert_eq!(state.inventory_tab, InventoryTab::Nearby);
20220 assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
20222 assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
20223 assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
20224 assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
20225 }
20226
20227 #[test]
20228 fn parse_bank_copper_amount_blank_and_zero_mean_all() {
20229 assert_eq!(parse_bank_copper_amount(""), Some(0));
20230 assert_eq!(parse_bank_copper_amount(" "), Some(0));
20231 assert_eq!(parse_bank_copper_amount("0"), Some(0));
20232 assert_eq!(parse_bank_copper_amount("250"), Some(250));
20233 assert_eq!(parse_bank_copper_amount("nope"), None);
20234 }
20235
20236 #[test]
20237 fn parse_storage_quantity_blank_and_zero_mean_all() {
20238 assert_eq!(parse_storage_quantity(""), Some(None));
20239 assert_eq!(parse_storage_quantity(" "), Some(None));
20240 assert_eq!(parse_storage_quantity("0"), Some(None));
20241 assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
20242 assert_eq!(parse_storage_quantity("nope"), None);
20243 }
20244
20245 #[test]
20246 fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
20247 assert!(worker_error_is_hud_noise("path stuck — repathing"));
20248 assert!(worker_error_is_hud_noise(
20249 "path stuck — nudged clear, repathing"
20250 ));
20251 assert!(worker_error_is_hud_noise(
20252 "returned to lodging after path failures"
20253 ));
20254 assert!(!worker_error_is_hud_noise(
20256 "path stuck — no lodging to reset to"
20257 ));
20258 assert!(!worker_error_is_hud_noise("cannot reach Eli — idling"));
20259 assert!(!worker_error_is_hud_noise(
20260 "no path to Oak Tree — idling at lodging"
20261 ));
20262 assert!(worker_error_is_transient(
20263 "no path to Oak Tree — trying next node"
20264 ));
20265 assert!(worker_error_is_transient(
20266 "no path to Oak Tree — continuing route"
20267 ));
20268 assert!(worker_error_is_hud_noise(
20269 "path unreachable (plan failures 0, leg 0)"
20270 ));
20271 assert!(!worker_error_is_hud_noise(
20272 "path unreachable (plan failures 0, leg 24)"
20273 ));
20274 assert!(worker_error_is_transient("storage full; continuing route"));
20275 assert!(!worker_error_is_transient(
20276 "storage full (Food Bank) — free chest space or reassign deposit"
20277 ));
20278 assert!(!worker_error_is_hud_noise(
20279 "storage full (Food Bank) — free chest space or reassign deposit"
20280 ));
20281 }
20282
20283 #[test]
20284 fn leaving_building_restores_outdoor_z_bands() {
20285 use flatland_protocol::{InteriorMapView, ZPlatformView};
20286
20287 let mut state = sample_state();
20288 state.z_platforms.clear();
20289 state.z_transitions.clear();
20290 state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
20291 state.interior_map = Some(InteriorMapView {
20292 building_id: "broker_hut".into(),
20293 blueprint_id: "broker_hut".into(),
20294 background_color: "#000".into(),
20295 default_floor_color: None,
20296 floor_height_m: 3.0,
20297 z_platforms: vec![ZPlatformView {
20298 id: "floor_0".into(),
20299 z: 0.0,
20300 x0: 0.0,
20301 y0: 0.0,
20302 x1: 8.0,
20303 y1: 8.0,
20304 }],
20305 z_transitions: vec![],
20306 rooms: vec![],
20307 room_doors: vec![],
20308 });
20309 state.sync_interior_map_context();
20310 assert_eq!(
20311 state.z_platforms.len(),
20312 1,
20313 "indoors installs interior platforms"
20314 );
20315 assert!(state.z_bands_outdoor_backup.is_some());
20316
20317 state.player.as_mut().unwrap().inside_building = None;
20318 state.sync_interior_map_context();
20319 assert!(
20320 state.z_platforms.is_empty(),
20321 "leaving must restore outdoor bands (empty), not leave interior platforms"
20322 );
20323 assert!(state.z_bands_outdoor_backup.is_none());
20324 assert!(state.interior_map.is_none());
20325 }
20326
20327 #[test]
20328 fn resource_node_route_label_prefers_friendly_label_with_suffix() {
20329 let node = ResourceNodeView {
20330 id: "crop-carrot-1_copy10".into(),
20331 label: "crop-carrot-1_copy10".into(),
20332 x: 0.0,
20333 y: 0.0,
20334 z: 0.0,
20335 item_template: "carrot".into(),
20336 state: ResourceNodeState::Available,
20337 blocking: false,
20338 blocking_radius_m: 0.5,
20339 harvest_off: false,
20340 tile_id: None,
20341 yaw: 0.0,
20342 pitch: 0.0,
20343 roll: 0.0,
20344 draw_scale: 1.0,
20345 sprite_mode: None,
20346 growth_progress: None,
20347 presentation_state: None,
20348 channel_start_tick: None,
20349 channel_end_tick: None,
20350 harvest_drop_templates: vec![],
20351 };
20352 let label = super::resource_node_route_label(&node);
20353 assert!(label.starts_with("Carrot ("), "got {label}");
20354 assert!(label.ends_with(')'), "got {label}");
20355
20356 let mut named = node;
20357 named.label = "Sweet Pad".into();
20358 named.id = "crop-carrot-a3f2b1c0".into();
20359 assert_eq!(super::resource_node_route_label(&named), "Sweet Pad (b1c0)");
20360 }
20361
20362 #[test]
20363 fn plot_public_label_uses_owner_zone_and_label() {
20364 let plot = flatland_protocol::PropertyPlotView {
20365 plot_id: uuid::Uuid::nil(),
20366 property_zone_id: "zone_a".into(),
20367 zone_label: Some("Starter Town East 1".into()),
20368 deed_instance_id: uuid::Uuid::nil(),
20369 x0: 0.0,
20370 y0: 0.0,
20371 x1: 4.0,
20372 y1: 4.0,
20373 upkeep_copper_per_day: 1,
20374 arrears_days: 0,
20375 is_mine: true,
20376 may_farm: true,
20377 purchase_basis_copper: 0,
20378 farm_public: false,
20379 public_tax_discount_bps: 0,
20380 farm_allow: vec![],
20381 owner_character_id: None,
20382 owner_label: Some("Madsin".into()),
20383 building_id: None,
20384 plot_code: "xyz1234a".into(),
20385 label: "Food Pad".into(),
20386 };
20387 assert_eq!(
20388 super::plot_public_label(&plot),
20389 "Madsin — Starter Town East 1 — Food Pad"
20390 );
20391 }
20392
20393 #[test]
20394 fn plot_public_label_uses_size_when_label_and_code_blank() {
20395 let plot = flatland_protocol::PropertyPlotView {
20396 plot_id: uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap(),
20397 property_zone_id: String::new(),
20398 zone_label: None,
20399 deed_instance_id: uuid::Uuid::nil(),
20400 x0: 10.0,
20401 y0: 20.0,
20402 x1: 18.0,
20403 y1: 28.0,
20404 upkeep_copper_per_day: 1,
20405 arrears_days: 0,
20406 is_mine: true,
20407 may_farm: true,
20408 purchase_basis_copper: 0,
20409 farm_public: false,
20410 public_tax_discount_bps: 0,
20411 farm_allow: vec![],
20412 owner_character_id: None,
20413 owner_label: None,
20414 building_id: None,
20415 plot_code: String::new(),
20416 label: String::new(),
20417 };
20418 assert_eq!(super::plot_public_label(&plot), "Homestead — Plot (8×8 m)");
20419 assert!(!super::plot_public_label(&plot).contains("19fe35f"));
20420 }
20421
20422 #[test]
20423 fn plot_stop_label_prefers_view_over_hex() {
20424 let plot_id = uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap();
20425 let plot = flatland_protocol::PropertyPlotView {
20426 plot_id,
20427 property_zone_id: "zone_a".into(),
20428 zone_label: Some("Starter Town East".into()),
20429 deed_instance_id: uuid::Uuid::nil(),
20430 x0: 0.0,
20431 y0: 0.0,
20432 x1: 4.0,
20433 y1: 4.0,
20434 upkeep_copper_per_day: 1,
20435 arrears_days: 0,
20436 is_mine: true,
20437 may_farm: true,
20438 purchase_basis_copper: 0,
20439 farm_public: false,
20440 public_tax_discount_bps: 0,
20441 farm_allow: vec![],
20442 owner_character_id: None,
20443 owner_label: Some("Madsin".into()),
20444 building_id: None,
20445 plot_code: "xyz1234a".into(),
20446 label: "Food Pad".into(),
20447 };
20448 assert_eq!(
20449 super::plot_stop_label(&[plot.clone()], plot_id),
20450 "Madsin — Starter Town East — Food Pad"
20451 );
20452 let missing = uuid::Uuid::parse_str("1a001590-0000-4000-8000-000000000002").unwrap();
20453 assert_eq!(super::plot_stop_label(&[plot], missing), "plot 1a001590");
20454 }
20455}