1use std::collections::{BTreeMap, VecDeque};
2use std::time::{Duration, Instant};
3
4use flatland_protocol::{
5 AbilityCooldownHud, BlueprintView, BodySlot, BuildingView, CastProgressHud, CombatHud,
6 CombatSlotHud, CombatTargetHud, DoorView, EntityId, EntityState, Intent, InteriorMapView,
7 LifeState, NpcView, RotationPreset, Seq, SessionId, TerrainKindView, TerrainZoneView, Tick,
8 ZPlatformView, ZTransitionView,
9};
10
11use crate::session::{PlayConnection, SessionEvent};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum CharacterSheetTab {
15 #[default]
16 Character,
17 Ledger,
18 Career,
19}
20
21impl CharacterSheetTab {
22 pub fn cycle(self) -> Self {
23 match self {
24 Self::Character => Self::Ledger,
25 Self::Ledger => Self::Career,
26 Self::Career => Self::Character,
27 }
28 }
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum LedgerPeriod {
33 #[default]
34 Day,
35 Week,
36 Month,
37 Lifetime,
38}
39
40impl LedgerPeriod {
41 pub fn label(self) -> &'static str {
42 match self {
43 Self::Day => "Day",
44 Self::Week => "Week",
45 Self::Month => "Month",
46 Self::Lifetime => "All",
47 }
48 }
49
50 pub fn cycle(self) -> Self {
51 match self {
52 Self::Day => Self::Week,
53 Self::Week => Self::Month,
54 Self::Month => Self::Lifetime,
55 Self::Lifetime => Self::Day,
56 }
57 }
58
59 pub fn from_digit(c: char) -> Option<Self> {
60 match c {
61 '1' => Some(Self::Day),
62 '2' => Some(Self::Week),
63 '3' => Some(Self::Month),
64 '4' => Some(Self::Lifetime),
65 _ => None,
66 }
67 }
68}
69
70const KEY_TEMPLATE: &str = "container_key";
72const PROP_LOCK_ID: &str = "lock_id";
73const PROP_OPENS_LOCK_ID: &str = "opens_lock_id";
74const PROP_OPENS_CONTAINER_NAME: &str = "opens_container_name";
75const PROP_CUSTOM_NAME: &str = "custom_name";
76const PROP_LOCKED: &str = "locked";
77
78fn stack_is_locked(stack: &flatland_protocol::ItemStack) -> bool {
79 stack
80 .props
81 .get(PROP_LOCKED)
82 .is_some_and(|v| v == "true" || v == "1")
83}
84
85const MAX_LOG_LINES: usize = 200;
86const MAX_SHOP_TRADE_LOG_LINES: usize = 40;
87const INTERACTION_RADIUS_M: f32 = 1.5;
88const DOOR_INTERACTION_RADIUS_M: f32 = 3.5;
89const QUEST_BOARD_INTERACTION_RADIUS_M: f32 = 3.0;
90const HARVEST_CLIENT_TIMEOUT: Duration = Duration::from_secs(12);
91const CRAFT_STAMINA_COST: f32 = 3.0;
93const WORKER_STEP_HOLD: Duration = Duration::from_millis(1200);
95
96#[derive(Debug, Clone, Default)]
98pub struct InventoryHint {
99 pub display_name: String,
100 pub category: String,
101 pub base_mass: Option<f32>,
102 pub base_volume: Option<f32>,
103 pub capacity_volume: Option<f32>,
104 pub stackable: bool,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
109pub enum RotationEditorMode {
110 #[default]
111 List,
112 EditSequence,
113 PickAbility,
114 EditLabel,
115}
116
117#[derive(Debug, Clone, Default)]
119pub struct RotationEditorState {
120 pub mode: RotationEditorMode,
121 pub list_index: usize,
122 pub ability_index: usize,
123 pub picker_index: usize,
124 pub draft: Option<RotationPreset>,
125 pub label_buffer: String,
126}
127
128impl RotationEditorState {
129 pub fn reset(&mut self) {
130 *self = Self::default();
131 }
132}
133
134pub const CONTAINER_RANGE_M: f32 = 3.0;
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum InventorySection {
144 Worn,
146 Person,
148 Nearby,
150}
151
152pub fn body_slot_label(slot: BodySlot) -> &'static str {
155 match slot {
156 BodySlot::Head => "Head",
157 BodySlot::Chest => "Chest",
158 BodySlot::Forearms => "Forearms",
159 BodySlot::Legs => "Legs",
160 BodySlot::Feet => "Feet",
161 BodySlot::Cloak => "Cloak",
162 BodySlot::Back => "Back",
163 BodySlot::Waist => "Waist",
164 BodySlot::Earrings => "Earrings",
165 BodySlot::Necklace => "Necklace",
166 BodySlot::Eyeglasses => "Eyeglasses",
167 BodySlot::RingLeft1 => "Ring L1",
168 BodySlot::RingLeft2 => "Ring L2",
169 BodySlot::RingRight1 => "Ring R1",
170 BodySlot::RingRight2 => "Ring R2",
171 }
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum EquipPaperdollRow {
176 Body { slot: BodySlot, filled: bool },
177 Mainhand { filled: bool },
178 Offhand { filled: bool, locked: bool },
179}
180
181pub fn equip_paperdoll_rows(state: &GameState) -> Vec<EquipPaperdollRow> {
182 let mut rows: Vec<EquipPaperdollRow> = BodySlot::ALL
183 .iter()
184 .map(|slot| EquipPaperdollRow::Body {
185 slot: *slot,
186 filled: state.worn.contains_key(slot),
187 })
188 .collect();
189 let two_hand = state.mainhand_hand_slots >= 2;
190 rows.push(EquipPaperdollRow::Mainhand {
191 filled: state.mainhand_template_id.is_some(),
192 });
193 rows.push(EquipPaperdollRow::Offhand {
194 filled: state.offhand_template_id.is_some(),
195 locked: two_hand,
196 });
197 rows
198}
199
200fn first_inventory_for_slot(state: &GameState, slot: BodySlot) -> Option<uuid::Uuid> {
201 for stack in &state.inventory_stacks {
202 let matches = stack
203 .equip_slot
204 .map(|s| s == slot || (is_client_ring(s) && is_client_ring(slot)))
205 .unwrap_or(false)
206 || guess_body_slot(&stack.template_id) == Some(slot);
207 if matches {
208 return stack.item_instance_id;
209 }
210 }
211 None
212}
213
214fn is_client_ring(slot: BodySlot) -> bool {
215 matches!(
216 slot,
217 BodySlot::RingLeft1
218 | BodySlot::RingLeft2
219 | BodySlot::RingRight1
220 | BodySlot::RingRight2
221 )
222}
223
224fn first_inventory_weapon(state: &GameState) -> Option<String> {
225 for stack in &state.inventory_stacks {
226 if stack.category.as_deref() == Some("weapon") {
227 return Some(stack.template_id.clone());
228 }
229 }
230 None
231}
232
233fn first_inventory_offhand(state: &GameState) -> Option<String> {
234 for stack in &state.inventory_stacks {
235 let cat = stack.category.as_deref().unwrap_or("");
236 if matches!(cat, "shield" | "offhand") {
237 return Some(stack.template_id.clone());
238 }
239 }
240 None
241}
242
243fn guess_body_slot(template_id: &str) -> Option<BodySlot> {
246 if template_id.contains("backpack") {
247 Some(BodySlot::Back)
248 } else if template_id.contains("belt") {
249 Some(BodySlot::Waist)
250 } else if template_id.contains("cloak") || template_id.contains("cape") {
251 Some(BodySlot::Cloak)
252 } else if template_id.contains("cap")
253 || template_id.contains("hat")
254 || template_id.contains("helm")
255 {
256 Some(BodySlot::Head)
257 } else if template_id.contains("shirt")
258 || template_id.contains("robe")
259 || template_id.contains("vest")
260 || template_id.contains("chest")
261 || template_id.contains("jerkin")
262 {
263 Some(BodySlot::Chest)
264 } else if template_id.contains("sleeves")
265 || template_id.contains("gloves")
266 || template_id.contains("gauntlets")
267 {
268 Some(BodySlot::Forearms)
269 } else if template_id.contains("pants") || template_id.contains("leggings") {
270 Some(BodySlot::Legs)
271 } else if template_id.contains("boots") || template_id.contains("shoes") {
272 Some(BodySlot::Feet)
273 } else if template_id.contains("earring") {
274 Some(BodySlot::Earrings)
275 } else if template_id.contains("necklace") || template_id.contains("amulet") {
276 Some(BodySlot::Necklace)
277 } else if template_id.contains("glass")
278 || template_id.contains("spectacles")
279 || template_id.contains("goggles")
280 {
281 Some(BodySlot::Eyeglasses)
282 } else if template_id.contains("ring") {
283 Some(BodySlot::RingLeft1)
284 } else {
285 None
286 }
287}
288
289#[derive(Debug, Clone)]
291pub struct InventoryRow {
292 pub depth: usize,
293 pub stack: flatland_protocol::ItemStack,
294 pub from: flatland_protocol::InventoryLocation,
296 pub from_parent_instance_id: Option<uuid::Uuid>,
298 pub is_equip_shell: bool,
300 pub is_chest_shell: bool,
302 pub section: InventorySection,
303}
304
305#[derive(Debug, Clone)]
307pub struct InventoryRowView {
308 pub depth: usize,
309 pub text: String,
311 pub title: String,
313 pub mass_kg: Option<f32>,
314 pub volume: Option<(f32, f32)>,
315}
316
317#[derive(Debug, Clone)]
319pub enum InventoryBrowserLine {
320 Section(String),
321 SlotLabel(String),
322 Hint(String),
323 Blank,
324 Item {
325 selectable_index: usize,
326 selected: bool,
327 depth: usize,
328 text: String,
329 title: String,
330 mass_kg: Option<f32>,
331 volume: Option<(f32, f32)>,
332 },
333}
334
335#[derive(Debug, Clone)]
338pub struct NearbyContainer {
339 pub view: flatland_protocol::PlacedContainerView,
340 pub distance_m: f32,
341 pub rows: Vec<InventoryRow>,
342}
343
344#[derive(Debug, Clone)]
346pub struct KeychainEntry {
347 pub stack: flatland_protocol::ItemStack,
348 pub stowed: bool,
349}
350
351#[derive(Debug, Clone)]
353pub struct MoveOption {
354 pub label: String,
355 pub kind: MoveOptionKind,
356}
357
358#[derive(Debug, Clone, PartialEq)]
359pub enum MoveOptionKind {
360 Move {
361 location: flatland_protocol::InventoryLocation,
362 parent_instance_id: Option<uuid::Uuid>,
363 },
364 PickupPlaced {
366 container_id: String,
367 nest_location: flatland_protocol::InventoryLocation,
368 nest_parent_instance_id: Option<uuid::Uuid>,
369 },
370 Use,
372 Drop,
373 Cancel,
374}
375
376#[derive(Debug, Clone)]
378pub struct MovePicker {
379 pub item_instance_id: uuid::Uuid,
380 pub from: flatland_protocol::InventoryLocation,
381 pub item_label: String,
382 pub template_id: String,
383 pub stack_quantity: u32,
384 pub quantity: u32,
385 pub options: Vec<MoveOption>,
386}
387
388#[derive(Debug, Clone)]
390pub struct DestroyPicker {
391 pub item_instance_id: uuid::Uuid,
392 pub from: flatland_protocol::InventoryLocation,
393 pub item_label: String,
394 pub stack_quantity: u32,
395 pub quantity: u32,
396}
397
398#[derive(Debug, Clone)]
400pub struct WorkerGiveOption {
401 pub item_instance_id: uuid::Uuid,
402 pub label: String,
403 pub quantity: u32,
404 pub template_id: String,
405}
406
407#[derive(Debug, Clone)]
409pub struct WorkerGivePicker {
410 pub worker_instance_id: String,
411 pub worker_label: String,
412 pub options: Vec<WorkerGiveOption>,
413}
414
415#[derive(Debug, Clone)]
417pub struct WorkerGiveTargetOption {
418 pub instance_id: String,
419 pub label: String,
420 pub distance_m: f32,
421}
422
423#[derive(Debug, Clone)]
425pub struct WorkerGiveTargetPicker {
426 pub item_instance_id: uuid::Uuid,
427 pub item_label: String,
428 pub quantity: Option<u32>,
429 pub options: Vec<WorkerGiveTargetOption>,
430}
431
432#[derive(Debug, Clone)]
434pub struct WorkerTakePicker {
435 pub worker_instance_id: String,
436 pub worker_label: String,
437 pub options: Vec<WorkerGiveOption>,
438}
439
440pub const WORKER_GIVE_RANGE_M: f32 = 4.0;
442
443#[derive(Debug, Clone)]
445pub struct WorkerTeachOption {
446 pub blueprint_id: String,
447 pub label: String,
448 pub cost_copper: u64,
449 pub min_level: u32,
450 pub worker_level: u32,
451 pub can_afford: bool,
452 pub level_ok: bool,
453}
454
455#[derive(Debug, Clone)]
457pub struct WorkerTeachPicker {
458 pub worker_instance_id: String,
459 pub worker_label: String,
460 pub worker_level: u32,
461 pub options: Vec<WorkerTeachOption>,
462}
463
464#[derive(Debug, Clone, Default)]
467pub struct StickyWorkerStep {
468 shown: String,
469 pending: String,
470 pending_since: Option<Instant>,
471}
472
473impl StickyWorkerStep {
474 fn from_label(label: String) -> Self {
475 Self {
476 shown: label.clone(),
477 pending: label,
478 pending_since: Some(Instant::now()),
479 }
480 }
481
482 fn observe(&mut self, label: &str, now: Instant) {
483 let pending_since = self.pending_since.unwrap_or(now);
484 if label == self.pending {
485 if self.shown != self.pending && now.duration_since(pending_since) >= WORKER_STEP_HOLD
486 {
487 self.shown = self.pending.clone();
488 }
489 return;
490 }
491 self.pending = label.to_string();
492 self.pending_since = Some(now);
493 if self.shown.is_empty() {
495 self.shown = self.pending.clone();
496 }
497 }
498}
499
500pub fn worker_error_is_transient(err: &str) -> bool {
502 let e = err.to_ascii_lowercase();
503 e.contains("continuing route")
504 || e.contains("no path")
505 || e.contains("storage full")
506 || e.starts_with("nothing to withdraw")
507}
508
509#[derive(Debug, Clone)]
511pub struct PendingWorkerJobAck {
512 pub seq: u32,
513 pub worker_instance_id: String,
514 pub worker_label: String,
515 pub idle: bool,
516 pub stop_count: usize,
517 pub prev_route: Option<flatland_protocol::WorkerRouteView>,
518 pub prev_mode: flatland_protocol::WorkerModeView,
519 pub prev_step_label: String,
520 pub prev_last_error: Option<String>,
521}
522
523fn push_inventory_rows(
524 rows: &mut Vec<InventoryRow>,
525 depth: usize,
526 stack: &flatland_protocol::ItemStack,
527 from: &flatland_protocol::InventoryLocation,
528 from_parent_instance_id: Option<uuid::Uuid>,
529 section: InventorySection,
530) {
531 rows.push(InventoryRow {
532 depth,
533 stack: stack.clone(),
534 from: from.clone(),
535 from_parent_instance_id,
536 is_equip_shell: false,
537 is_chest_shell: false,
538 section,
539 });
540 for child in &stack.contents {
541 push_inventory_rows(
542 rows,
543 depth + 1,
544 child,
545 from,
546 stack.item_instance_id,
547 section,
548 );
549 }
550}
551
552#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
553pub enum ShopTab {
554 #[default]
555 Buy,
556 Sell,
557}
558
559#[derive(Debug, Clone)]
560pub struct NpcChatState {
561 pub npc_id: String,
562 pub npc_label: String,
563 pub lines: Vec<String>,
564 pub input: String,
565 pub pending: bool,
566 pub talk_depth: flatland_protocol::NpcTalkDepth,
567 pub trade_allowed: bool,
568 pub banner: Option<String>,
569}
570
571impl Default for NpcChatState {
572 fn default() -> Self {
573 Self {
574 npc_id: String::new(),
575 npc_label: String::new(),
576 lines: Vec::new(),
577 input: String::new(),
578 pending: false,
579 talk_depth: flatland_protocol::NpcTalkDepth::Full,
580 trade_allowed: true,
581 banner: None,
582 }
583 }
584}
585
586#[derive(Debug, Clone)]
587pub struct GameState {
588 pub session_id: SessionId,
589 pub entity_id: EntityId,
590 pub character_id: Option<uuid::Uuid>,
592 pub tick: Tick,
593 pub chunk_rev: u64,
594 pub content_rev: u64,
595 pub publish_rev: u64,
596 pub entities: Vec<EntityState>,
597 pub player: Option<EntityState>,
598 pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
599 pub ground_drops: Vec<flatland_protocol::GroundDropView>,
600 pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
601 pub buildings: Vec<BuildingView>,
602 pub doors: Vec<DoorView>,
603 pub interior_map: Option<InteriorMapView>,
604 pub npcs: Vec<NpcView>,
605 pub blueprints: Vec<BlueprintView>,
606 pub world_width_m: f32,
607 pub world_height_m: f32,
608 pub terrain_zones: Vec<TerrainZoneView>,
609 pub z_platforms: Vec<ZPlatformView>,
610 pub z_transitions: Vec<ZTransitionView>,
611 pub world_clock: flatland_protocol::WorldClock,
612 pub inventory: std::collections::HashMap<String, u32>,
613 pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
614 pub logs: VecDeque<String>,
615 pub intents_sent: u64,
616 pub ticks_received: u64,
617 pub connected: bool,
618 pub disconnect_reason: Option<String>,
619 pub show_stats: bool,
620 pub show_equip_menu: bool,
621 pub equip_menu_index: usize,
622 pub show_craft_menu: bool,
623 pub craft_menu_index: usize,
624 pub craft_batch_quantity: u32,
626 pub show_shop_menu: bool,
627 pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
628 pub shop_tab: ShopTab,
629 pub shop_menu_index: usize,
630 pub shop_quantity: u32,
631 pub shop_trade_log: VecDeque<String>,
633 pub show_npc_verb_menu: bool,
634 pub npc_verb_target: Option<String>,
635 pub npc_verb_index: usize,
636 pub show_npc_chat: bool,
637 pub npc_chat: Option<NpcChatState>,
638 pub show_inventory_menu: bool,
639 pub inventory_menu_index: usize,
640 pub show_move_picker: bool,
641 pub move_picker_index: usize,
642 pub move_picker: Option<MovePicker>,
643 pub show_destroy_picker: bool,
644 pub destroy_confirm_pending: bool,
645 pub destroy_picker: Option<DestroyPicker>,
646 pub show_rename_prompt: bool,
648 pub show_worker_rename: bool,
650 pub rename_buffer: String,
651 pub combat_target: Option<EntityId>,
653 pub combat_target_label: Option<String>,
654 pub in_combat: bool,
655 pub auto_attack: bool,
656 pub combat_has_los: bool,
657 pub attack_cd_ticks: u64,
658 pub gcd_ticks: u64,
659 pub weapon_ability_id: String,
660 pub mainhand_template_id: Option<String>,
661 pub mainhand_label: Option<String>,
662 pub offhand_template_id: Option<String>,
663 pub offhand_label: Option<String>,
664 pub mainhand_hand_slots: u8,
665 pub defense: Option<flatland_protocol::DefenseHud>,
666 pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
668 pub carry_mass: f32,
669 pub carry_mass_max: f32,
670 pub encumbrance: flatland_protocol::EncumbranceState,
671 pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
673 pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
675 pub combat_target_detail: Option<CombatTargetHud>,
676 pub cast_progress: Option<CastProgressHud>,
677 pub ability_cooldowns: Vec<AbilityCooldownHud>,
678 pub blocking_active: bool,
679 pub max_target_slots: u8,
680 pub combat_slots: Vec<CombatSlotHud>,
681 pub rotation_presets: Vec<RotationPreset>,
682 pub show_loadout_menu: bool,
683 pub show_keychain_menu: bool,
684 pub keychain_menu_index: usize,
685 pub show_rotation_editor: bool,
686 pub loadout_menu_index: usize,
687 pub rotation_editor: RotationEditorState,
688 pub harvest_in_progress: bool,
690 pub harvest_started_at: Option<Instant>,
692 pub pending_craft_ack: Option<(u32, String, u32)>,
694 pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
695 pub interactables: Vec<flatland_protocol::InteractableView>,
696 pub ledger: Option<flatland_protocol::PlayerLedgerView>,
697 pub career: Option<flatland_protocol::PlayerCareerView>,
698 pub character_sheet_tab: CharacterSheetTab,
699 pub ledger_period: LedgerPeriod,
700 pub show_quest_offer: bool,
701 pub pending_quest_offer: Option<flatland_protocol::QuestOffer>,
702 pub show_quest_menu: bool,
703 pub quest_menu_index: usize,
704 pub quest_withdraw_confirm: bool,
705 pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
706 pub show_workers_menu: bool,
707 pub workers_menu_index: usize,
708 pub workers_menu_compact: bool,
710 pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
713 pub show_worker_give_picker: bool,
715 pub worker_give_picker_index: usize,
716 pub worker_give_picker: Option<WorkerGivePicker>,
717 pub show_worker_give_target_picker: bool,
719 pub worker_give_target_picker_index: usize,
720 pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
721 pub show_worker_take_picker: bool,
723 pub worker_take_picker_index: usize,
724 pub worker_take_picker: Option<WorkerTakePicker>,
725 pub show_worker_teach_picker: bool,
727 pub worker_teach_picker_index: usize,
728 pub worker_teach_picker: Option<WorkerTeachPicker>,
729 pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
731 pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
733 pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
735}
736
737impl GameState {
738 pub fn push_log(&mut self, line: impl Into<String>) {
739 self.logs.push_back(line.into());
740 while self.logs.len() > MAX_LOG_LINES {
741 self.logs.pop_front();
742 }
743 }
744
745 pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
746 self.shop_trade_log.push_back(line.into());
747 while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
748 self.shop_trade_log.pop_front();
749 }
750 }
751
752 pub fn clear_shop_trade_log(&mut self) {
753 self.shop_trade_log.clear();
754 }
755
756 fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
757 if !self.show_shop_menu {
758 return;
759 }
760 let msg = notice.message.trim();
761 if msg.is_empty() {
762 return;
763 }
764 if notice.coins_delta != 0
765 || msg.starts_with("Bought ")
766 || msg.starts_with("Sold ")
767 || msg.contains("taught you how to craft")
768 || msg.starts_with("need ")
769 {
770 self.push_shop_trade_log(msg);
771 }
772 }
773
774 pub fn is_alive(&self) -> bool {
775 self.player
776 .as_ref()
777 .and_then(|p| p.vitals)
778 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
779 .unwrap_or(true)
780 }
781
782 pub fn npc_verb_options(&self) -> Vec<&'static str> {
784 let Some(ref id) = self.npc_verb_target else {
785 return vec![];
786 };
787 let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
788 return vec!["Talk"];
789 };
790 if npc.can_trade || Self::npc_role_can_trade(npc.role.as_str()) {
791 vec!["Talk", "Trade"]
792 } else {
793 vec!["Talk"]
794 }
795 }
796
797 fn npc_role_can_trade(role: &str) -> bool {
798 matches!(role, "broker" | "cook" | "farmer" | "merchant")
799 }
800
801 pub fn clear_harvest_state(&mut self) {
802 self.harvest_in_progress = false;
803 self.harvest_started_at = None;
804 }
805
806 fn harvest_state_stale(&self) -> bool {
807 match self.harvest_started_at {
808 Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
809 None => self.harvest_in_progress,
810 }
811 }
812
813 pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
814 self.player.as_ref().and_then(|p| p.vitals)
815 }
816
817 pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
818 let materials_ok = blueprint.inputs.iter().all(|input| {
819 self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
820 });
821 let tools_ok = blueprint
822 .required_tools
823 .iter()
824 .all(|tool| self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1);
825 let station_ok = match blueprint.station.as_deref() {
826 None | Some("hand") => true,
827 Some(tag) => self.player_at_station_tag(tag),
828 };
829 materials_ok && tools_ok && station_ok
830 }
831
832 pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
833 if !self.can_craft_blueprint(blueprint) {
834 return 0;
835 }
836 let mut limit = u32::MAX;
837 for input in &blueprint.inputs {
838 if input.quantity == 0 {
839 continue;
840 }
841 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
842 limit = limit.min(have / input.quantity);
843 }
844 for tool in &blueprint.required_tools {
845 if tool.consumed {
846 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
847 limit = limit.min(have);
848 }
849 }
850 let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
851 if CRAFT_STAMINA_COST > 0.0 {
852 limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
853 }
854 limit
855 }
856
857 pub fn clamp_craft_batch_quantity(&mut self) {
858 let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
859 self.craft_batch_quantity = 1;
860 return;
861 };
862 let max = self.max_craft_batches(bp).max(1);
863 self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
864 }
865
866 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
867 let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
868 return;
869 };
870 let max = self.max_craft_batches(&bp).max(1);
871 let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
872 self.craft_batch_quantity = next as u32;
873 }
874
875 pub fn craft_batch_set_max(&mut self) {
876 let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
877 return;
878 };
879 let max = self.max_craft_batches(&bp);
880 self.craft_batch_quantity = if max == 0 { 1 } else { max };
881 }
882
883 pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
884 let preserve_ui = self.show_shop_menu;
885 let tab = self.shop_tab;
886 let index = self.shop_menu_index;
887 let qty = self.shop_quantity;
888
889 self.show_shop_menu = true;
890 self.show_craft_menu = false;
891 self.show_inventory_menu = false;
892 self.show_stats = false;
893 if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
894 self.npc_verb_target = Some(catalog.npc_id.clone());
895 }
896 self.shop_catalog = Some(catalog);
897
898 if preserve_ui {
899 self.shop_tab = tab;
900 self.shop_menu_index = index;
901 self.shop_quantity = qty;
902 } else {
903 self.shop_tab = ShopTab::Buy;
904 self.shop_menu_index = 0;
905 self.shop_quantity = 1;
906 self.clear_shop_trade_log();
907 }
908 self.show_npc_verb_menu = false;
909 self.clamp_shop_selection();
910 }
911
912 pub fn shop_list_len(&self) -> usize {
913 let Some(catalog) = &self.shop_catalog else {
914 return 0;
915 };
916 match self.shop_tab {
917 ShopTab::Buy => catalog.sells.len(),
918 ShopTab::Sell => catalog.buys.len(),
919 }
920 }
921
922 pub fn shop_menu_move(&mut self, delta: i32) {
923 let n = self.shop_list_len();
924 if n == 0 {
925 return;
926 }
927 let idx = self.shop_menu_index as i32;
928 let next = (idx + delta).rem_euclid(n as i32);
929 self.shop_menu_index = next as usize;
930 self.clamp_shop_quantity();
931 }
932
933 pub fn shop_quantity_adjust(&mut self, delta: i32) {
934 let max = self.shop_quantity_max();
935 if max == 0 {
936 self.shop_quantity = 0;
937 return;
938 }
939 let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
940 self.shop_quantity = next as u32;
941 }
942
943 pub(crate) fn clamp_shop_selection(&mut self) {
944 let n = self.shop_list_len();
945 if n == 0 {
946 self.shop_menu_index = 0;
947 } else {
948 self.shop_menu_index = self.shop_menu_index.min(n - 1);
949 }
950 self.clamp_shop_quantity();
951 }
952
953 fn shop_quantity_max(&self) -> u32 {
954 let Some(catalog) = &self.shop_catalog else {
955 return 1;
956 };
957 match self.shop_tab {
958 ShopTab::Buy => {
959 if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
960 if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
961 return 1;
962 }
963 }
964 99
965 }
966 ShopTab::Sell => catalog
967 .buys
968 .get(self.shop_menu_index)
969 .map(|l| l.quantity)
970 .unwrap_or(0),
971 }
972 }
973
974 pub fn shop_quantity_set_max(&mut self) {
975 self.shop_quantity = self.shop_quantity_max();
976 }
977
978 fn clamp_shop_quantity(&mut self) {
979 let max = self.shop_quantity_max();
980 if max == 0 {
981 self.shop_quantity = 0;
982 } else {
983 self.shop_quantity = self.shop_quantity.max(1).min(max);
984 }
985 }
986
987 pub fn player_at_station_tag(&self, tag: &str) -> bool {
988 let Some(id) = self.effective_inside_building() else {
989 return false;
990 };
991 self.buildings
992 .iter()
993 .find(|b| b.id == id)
994 .is_some_and(|b| b.tags.iter().any(|t| t == tag))
995 }
996
997 pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
999 if self.can_craft_blueprint(blueprint) {
1000 return None;
1001 }
1002 let mut missing = Vec::new();
1003 for input in &blueprint.inputs {
1004 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
1005 if have < input.quantity {
1006 missing.push(format!(
1007 "{}×{} (have {have})",
1008 input.quantity, input.template_id
1009 ));
1010 }
1011 }
1012 for tool in &blueprint.required_tools {
1013 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
1014 if have < 1 {
1015 missing.push(format!("tool: {}", tool.item));
1016 }
1017 }
1018 if let Some(station) = blueprint.station.as_deref() {
1019 if station != "hand" && !self.player_at_station_tag(station) {
1020 missing.push(format!("station: {station} (enter building)"));
1021 }
1022 }
1023 if missing.is_empty() {
1024 None
1025 } else {
1026 Some(missing.join(", "))
1027 }
1028 }
1029
1030 pub fn player_entity(&self) -> Option<&EntityState> {
1031 self.player
1032 .as_ref()
1033 .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
1034 }
1035
1036 pub fn player_position(&self) -> (f32, f32) {
1037 let (x, y, _) = self.player_position_with_z();
1038 (x, y)
1039 }
1040
1041 pub fn player_position_with_z(&self) -> (f32, f32, f32) {
1042 if let Some(p) = self.player_entity() {
1043 (
1044 p.transform.position.x,
1045 p.transform.position.y,
1046 p.transform.position.z,
1047 )
1048 } else {
1049 (0.0, 0.0, 0.0)
1050 }
1051 }
1052
1053 pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
1054 let mut rows: Vec<(String, u32, String)> = self
1055 .inventory
1056 .iter()
1057 .filter(|(_, q)| **q > 0)
1058 .map(|(id, qty)| {
1059 let label = self
1060 .inventory_hints
1061 .get(id)
1062 .map(|h| h.display_name.clone())
1063 .unwrap_or_else(|| id.clone());
1064 (id.clone(), *qty, label)
1065 })
1066 .collect();
1067 rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
1068 rows
1069 }
1070
1071 pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
1072 self.inventory_hints
1073 .get(template_id)
1074 .map(|h| h.category.as_str())
1075 .filter(|c| !c.is_empty())
1076 }
1077
1078 pub fn item_base_mass(&self, template_id: &str) -> f32 {
1079 self.inventory_hints
1080 .get(template_id)
1081 .and_then(|h| h.base_mass)
1082 .unwrap_or(0.5)
1083 }
1084
1085 pub fn item_base_volume(&self, template_id: &str) -> f32 {
1086 self.inventory_hints
1087 .get(template_id)
1088 .and_then(|h| h.base_volume)
1089 .unwrap_or(1.0)
1090 }
1091
1092 pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
1093 let unit = stack
1094 .base_mass
1095 .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
1096 unit * stack.quantity as f32
1097 }
1098
1099 fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
1100 let unit = stack.base_volume.unwrap_or(1.0);
1101 unit * stack.quantity as f32
1102 + stack
1103 .contents
1104 .iter()
1105 .map(Self::stack_tree_volume)
1106 .sum::<f32>()
1107 }
1108
1109 fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
1110 contents.iter().map(Self::stack_tree_volume).sum()
1111 }
1112
1113 fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
1114 self.inventory_hints
1115 .get(template_id)
1116 .and_then(|h| h.capacity_volume)
1117 .filter(|c| *c > 0.0)
1118 }
1119
1120 fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
1121 stack
1122 .capacity_volume
1123 .filter(|c| *c > 0.0)
1124 .or_else(|| self.template_capacity_volume(&stack.template_id))
1125 }
1126
1127 pub fn container_volume_label(&self, row: &InventoryRow) -> String {
1129 let Some((used, cap)) = self.container_volume_stats(row) else {
1130 return String::new();
1131 };
1132 let free = (cap - used).max(0.0);
1133 format!(" vol {used:.0}/{cap:.0} ({free:.0} free)")
1134 }
1135
1136 fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
1137 if row.is_chest_shell {
1138 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
1139 return None;
1140 };
1141 let chest = self
1142 .placed_containers
1143 .iter()
1144 .find(|c| c.id == *container_id)?;
1145 let cap = self
1146 .stack_capacity_volume(&row.stack)
1147 .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
1148 let used = if chest.accessible {
1149 Self::contents_used_volume(&chest.contents)
1150 } else {
1151 0.0
1152 };
1153 return Some((used, cap));
1154 }
1155
1156 let cap = self.stack_capacity_volume(&row.stack)?;
1157 let used = Self::contents_used_volume(&row.stack.contents);
1158 Some((used, cap))
1159 }
1160
1161 pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
1162 if row.is_chest_shell {
1163 return true;
1164 }
1165 if row.is_equip_shell {
1166 return self.inventory_item_category(&row.stack.template_id) == Some("container");
1167 }
1168 self.inventory_item_category(&row.stack.template_id) == Some("container")
1169 || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
1170 }
1171
1172 fn container_stack_for(
1173 &self,
1174 location: &flatland_protocol::InventoryLocation,
1175 parent_instance_id: Option<uuid::Uuid>,
1176 ) -> Option<flatland_protocol::ItemStack> {
1177 match location {
1178 flatland_protocol::InventoryLocation::Root => {
1179 let pid = parent_instance_id?;
1180 self.find_stack_by_instance(&self.inventory_stacks, pid)
1181 }
1182 flatland_protocol::InventoryLocation::Worn { slot } => {
1183 let worn = self.worn.get(slot)?;
1184 if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
1185 Some(worn.clone())
1186 } else {
1187 self.find_stack_by_instance(&worn.contents, parent_instance_id?)
1188 }
1189 }
1190 flatland_protocol::InventoryLocation::Placed { container_id } => {
1191 let chest = self
1192 .placed_containers
1193 .iter()
1194 .find(|c| c.id == *container_id)?;
1195 if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
1196 Some(flatland_protocol::ItemStack {
1197 template_id: chest.template_id.clone(),
1198 quantity: 1,
1199 item_instance_id: chest.item_instance_id,
1200 props: Default::default(),
1201 contents: chest.contents.clone(),
1202 display_name: Some(chest.display_name.clone()),
1203 category: Some("container".into()),
1204 capacity_volume: self
1205 .inventory_hints
1206 .get(&chest.template_id)
1207 .and_then(|h| h.capacity_volume),
1208 worker_lodging_capacity: chest.worker_lodging_capacity,
1209 ..Default::default()
1210 })
1211 } else {
1212 self.find_stack_by_instance(&chest.contents, parent_instance_id?)
1213 }
1214 }
1215 flatland_protocol::InventoryLocation::Keychain => None,
1216 }
1217 }
1218
1219 fn find_stack_by_instance(
1220 &self,
1221 stacks: &[flatland_protocol::ItemStack],
1222 instance_id: uuid::Uuid,
1223 ) -> Option<flatland_protocol::ItemStack> {
1224 for stack in stacks {
1225 if stack.item_instance_id == Some(instance_id) {
1226 return Some(stack.clone());
1227 }
1228 if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
1229 return Some(found);
1230 }
1231 }
1232 None
1233 }
1234
1235 pub fn max_movable_to(
1237 &self,
1238 template_id: &str,
1239 stack_qty: u32,
1240 from: &flatland_protocol::InventoryLocation,
1241 to: &flatland_protocol::InventoryLocation,
1242 parent_instance_id: Option<uuid::Uuid>,
1243 ) -> u32 {
1244 let unit_vol = self.item_base_volume(template_id);
1245 let unit_mass = self.item_base_mass(template_id);
1246 let mut limit = stack_qty;
1247
1248 if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
1249 let cap = parent
1250 .capacity_volume
1251 .or_else(|| {
1252 self.inventory_hints
1253 .get(&parent.template_id)
1254 .and_then(|h| h.capacity_volume)
1255 })
1256 .unwrap_or(0.0);
1257 if cap > 0.0 && unit_vol > 0.0 {
1258 let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
1259 limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
1260 }
1261 }
1262
1263 let to_person = matches!(
1264 to,
1265 flatland_protocol::InventoryLocation::Root
1266 | flatland_protocol::InventoryLocation::Worn { .. }
1267 );
1268 let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
1269 if to_person && from_placed && unit_mass > 0.0 {
1270 let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
1271 if self.encumbrance == flatland_protocol::EncumbranceState::Over {
1272 limit = 0;
1273 } else {
1274 limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
1275 }
1276 }
1277
1278 limit.max(0).min(stack_qty)
1279 }
1280
1281 pub fn move_picker_max_at_selection(&self) -> u32 {
1282 let Some(picker) = &self.move_picker else {
1283 return 1;
1284 };
1285 let Some(opt) = picker.options.get(self.move_picker_index) else {
1286 return picker.stack_quantity;
1287 };
1288 match &opt.kind {
1289 MoveOptionKind::Cancel
1290 | MoveOptionKind::Drop
1291 | MoveOptionKind::Use
1292 | MoveOptionKind::PickupPlaced { .. } => picker.stack_quantity,
1293 MoveOptionKind::Move {
1294 location,
1295 parent_instance_id,
1296 } => self.max_movable_to(
1297 &picker.template_id,
1298 picker.stack_quantity,
1299 &picker.from,
1300 location,
1301 *parent_instance_id,
1302 ),
1303 }
1304 }
1305
1306 pub fn clamp_move_picker_quantity(&mut self) {
1307 let max = self.move_picker_max_at_selection();
1308 if let Some(picker) = &mut self.move_picker {
1309 if max == 0 {
1310 picker.quantity = 1;
1311 } else {
1312 picker.quantity = picker.quantity.clamp(1, max);
1313 }
1314 }
1315 }
1316
1317 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
1318 let max = self.move_picker_max_at_selection().max(1);
1319 if let Some(picker) = &mut self.move_picker {
1320 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
1321 picker.quantity = next as u32;
1322 }
1323 }
1324
1325 pub fn move_picker_set_quantity_max(&mut self) {
1326 let max = self.move_picker_max_at_selection();
1327 if let Some(picker) = &mut self.move_picker {
1328 picker.quantity = if max == 0 {
1329 1
1330 } else {
1331 max.min(picker.stack_quantity)
1332 };
1333 }
1334 }
1335
1336 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
1337 if let Some(picker) = &mut self.destroy_picker {
1338 let max = picker.stack_quantity.max(1);
1339 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
1340 picker.quantity = next as u32;
1341 }
1342 }
1343
1344 pub fn destroy_picker_set_quantity_max(&mut self) {
1345 if let Some(picker) = &mut self.destroy_picker {
1346 picker.quantity = picker.stack_quantity.max(1);
1347 }
1348 }
1349
1350 pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
1351 let have = self.inventory.get(template_id).copied().unwrap_or(0);
1352 (have, have >= need)
1353 }
1354
1355 pub fn currency_display(&self) -> String {
1356 crate::currency::currency_line(&self.inventory)
1357 }
1358
1359 pub fn in_shallow_water(&self) -> bool {
1361 let (px, py) = self.player_position();
1362 self.terrain_at(px, py)
1363 .is_some_and(|k| k == TerrainKindView::ShallowWater)
1364 }
1365
1366 pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
1367 self.terrain_zone_at(x, y).map(|z| z.kind)
1368 }
1369
1370 pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
1372 self.terrain_zones
1373 .iter()
1374 .enumerate()
1375 .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
1376 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
1377 .map(|(_, z)| z)
1378 }
1379
1380 pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
1382 self.terrain_zone_at(x, y)
1383 .map(|z| z.elevation)
1384 .unwrap_or(0.0)
1385 }
1386
1387 pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
1389 const TOL: f32 = 0.35;
1390 let mut levels = vec![self.elevation_at(x, y)];
1391 for p in &self.z_platforms {
1392 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
1393 levels.push(p.z);
1394 }
1395 }
1396 levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1397 levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
1398 levels
1399 }
1400
1401 pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
1402 const TOL: f32 = 0.35;
1403 self.walkable_levels_at(x, y)
1404 .iter()
1405 .any(|&l| (l - z).abs() <= TOL)
1406 }
1407
1408 pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
1409 let mut top = self.elevation_at(x, y);
1410 for p in &self.z_platforms {
1411 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
1412 top = top.max(p.z);
1413 }
1414 }
1415 top
1416 }
1417
1418 pub fn effective_inside_building(&self) -> Option<String> {
1420 self.player_entity().and_then(|p| p.inside_building.clone())
1421 }
1422
1423 pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
1424 self.inventory_stacks = stacks.to_vec();
1425 self.inventory.clear();
1426 self.inventory_hints.clear();
1427 fn walk(
1428 stacks: &[flatland_protocol::ItemStack],
1429 inventory: &mut std::collections::HashMap<String, u32>,
1430 hints: &mut std::collections::HashMap<String, InventoryHint>,
1431 ) {
1432 for stack in stacks {
1433 *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
1434 if stack.display_name.is_some()
1435 || stack.category.is_some()
1436 || stack.base_mass.is_some()
1437 || stack.base_volume.is_some()
1438 {
1439 hints.insert(
1440 stack.template_id.clone(),
1441 InventoryHint {
1442 display_name: stack
1443 .display_name
1444 .clone()
1445 .unwrap_or_else(|| stack.template_id.clone()),
1446 category: stack.category.clone().unwrap_or_default(),
1447 base_mass: stack.base_mass,
1448 base_volume: stack.base_volume,
1449 capacity_volume: stack.capacity_volume,
1450 stackable: stack.stackable.unwrap_or(true),
1451 },
1452 );
1453 }
1454 walk(&stack.contents, inventory, hints);
1455 }
1456 }
1457 walk(stacks, &mut self.inventory, &mut self.inventory_hints);
1458 for item in self.worn.values() {
1460 walk(
1461 std::slice::from_ref(item),
1462 &mut self.inventory,
1463 &mut self.inventory_hints,
1464 );
1465 }
1466 }
1467
1468 pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1471 let subtract_items =
1472 notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
1473 for stack in ¬ice.inventory_delta {
1474 if stack.quantity == 0 {
1475 continue;
1476 }
1477 if subtract_items {
1478 crate::currency::drain_template_stacks(
1479 &mut self.inventory_stacks,
1480 &stack.template_id,
1481 stack.quantity,
1482 );
1483 continue;
1484 }
1485 let stackable = self
1486 .inventory_hints
1487 .get(&stack.template_id)
1488 .map(|h| h.stackable)
1489 .or(stack.stackable)
1490 .unwrap_or(true);
1491 if stackable {
1492 if let Some(existing) = self
1493 .inventory_stacks
1494 .iter_mut()
1495 .find(|s| s.template_id == stack.template_id)
1496 {
1497 existing.quantity = existing.quantity.saturating_add(stack.quantity);
1498 if stack.display_name.is_some() {
1499 existing.display_name = stack.display_name.clone();
1500 }
1501 if stack.category.is_some() {
1502 existing.category = stack.category.clone();
1503 }
1504 continue;
1505 }
1506 }
1507 self.inventory_stacks.push(stack.clone());
1508 }
1509 if notice.coins_delta != 0 {
1510 crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
1511 }
1512 if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
1513 let stacks = self.inventory_stacks.clone();
1514 self.sync_inventory_from_stacks(&stacks);
1515 }
1516 self.record_shop_trade_notice(notice);
1517 }
1518
1519 pub fn worn_rows(&self) -> Vec<InventoryRow> {
1524 let mut rows = Vec::new();
1525 for (slot, item) in &self.worn {
1526 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
1527 rows.push(InventoryRow {
1528 depth: 0,
1529 stack: item.clone(),
1530 from: from.clone(),
1531 from_parent_instance_id: None,
1532 is_equip_shell: true,
1533 is_chest_shell: false,
1534 section: InventorySection::Worn,
1535 });
1536 for child in &item.contents {
1537 push_inventory_rows(
1538 &mut rows,
1539 1,
1540 child,
1541 &from,
1542 item.item_instance_id,
1543 InventorySection::Worn,
1544 );
1545 }
1546 }
1547 rows
1548 }
1549
1550 pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
1552 self.inventory_stacks
1553 .iter()
1554 .filter_map(|stack| {
1555 let item_instance_id = stack.item_instance_id?;
1556 let label = stack
1557 .display_name
1558 .clone()
1559 .unwrap_or_else(|| stack.template_id.clone());
1560 let label = if stack.quantity > 1 {
1561 format!("{label} ×{}", stack.quantity)
1562 } else {
1563 label
1564 };
1565 Some(WorkerGiveOption {
1566 item_instance_id,
1567 label,
1568 quantity: stack.quantity,
1569 template_id: stack.template_id.clone(),
1570 })
1571 })
1572 .collect()
1573 }
1574
1575 pub fn teachable_blueprint_options(
1577 &self,
1578 worker: &flatland_protocol::HiredWorkerView,
1579 ) -> Vec<WorkerTeachOption> {
1580 let copper = crate::currency::copper_from_counts(&self.inventory);
1581 let mut options: Vec<WorkerTeachOption> = self
1582 .blueprints
1583 .iter()
1584 .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
1585 .map(|bp| {
1586 let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
1587 let cost = bp.worker_train_copper;
1588 WorkerTeachOption {
1589 blueprint_id: bp.id.clone(),
1590 label: if bp.label.is_empty() {
1591 bp.id.clone()
1592 } else {
1593 bp.label.clone()
1594 },
1595 cost_copper: cost,
1596 min_level,
1597 worker_level: worker.level,
1598 can_afford: copper >= cost,
1599 level_ok: worker.level >= min_level,
1600 }
1601 })
1602 .collect();
1603 options.sort_by(|a, b| a.label.cmp(&b.label));
1604 options
1605 }
1606
1607 pub fn person_rows(&self) -> Vec<InventoryRow> {
1609 let mut rows = Vec::new();
1610 for stack in &self.inventory_stacks {
1611 push_inventory_rows(
1612 &mut rows,
1613 0,
1614 stack,
1615 &flatland_protocol::InventoryLocation::Root,
1616 None,
1617 InventorySection::Person,
1618 );
1619 }
1620 rows
1621 }
1622
1623 pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
1625 let mut rows = self.worn_rows();
1626 rows.extend(self.person_rows());
1627 rows.into_iter().map(|r| (r.depth, r.stack)).collect()
1628 }
1629
1630 pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
1634 let (px, py) = self.player_position();
1635 let mut list: Vec<NearbyContainer> = self
1636 .placed_containers
1637 .iter()
1638 .filter_map(|c| {
1639 let distance_m = (c.x - px).hypot(c.y - py);
1640 if distance_m > CONTAINER_RANGE_M {
1641 return None;
1642 }
1643 let mut rows = Vec::new();
1644 let from = flatland_protocol::InventoryLocation::Placed {
1645 container_id: c.id.clone(),
1646 };
1647 rows.push(InventoryRow {
1648 depth: 0,
1649 stack: flatland_protocol::ItemStack {
1650 template_id: c.template_id.clone(),
1651 quantity: 1,
1652 item_instance_id: c.item_instance_id,
1653 props: Default::default(),
1654 contents: Vec::new(),
1655 display_name: Some(c.display_name.clone()),
1656 category: Some("container".into()),
1657 capacity_volume: c.capacity_volume,
1658 worker_lodging_capacity: c.worker_lodging_capacity,
1659 ..Default::default()
1660 },
1661 from: from.clone(),
1662 from_parent_instance_id: None,
1663 is_equip_shell: false,
1664 is_chest_shell: true,
1665 section: InventorySection::Nearby,
1666 });
1667 if c.accessible {
1668 for child in &c.contents {
1669 push_inventory_rows(
1670 &mut rows,
1671 1,
1672 child,
1673 &from,
1674 c.item_instance_id,
1675 InventorySection::Nearby,
1676 );
1677 }
1678 }
1679 Some(NearbyContainer {
1680 view: c.clone(),
1681 distance_m,
1682 rows,
1683 })
1684 })
1685 .collect();
1686 list.sort_by(|a, b| {
1687 a.distance_m
1688 .partial_cmp(&b.distance_m)
1689 .unwrap_or(std::cmp::Ordering::Equal)
1690 });
1691 list
1692 }
1693
1694 pub fn nearest_placed_container(
1696 &self,
1697 max_dist: f32,
1698 ) -> Option<flatland_protocol::PlacedContainerView> {
1699 let (px, py) = self.player_position();
1700 self.placed_containers
1701 .iter()
1702 .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
1703 .min_by(|a, b| {
1704 let da = (a.x - px).hypot(a.y - py);
1705 let db = (b.x - px).hypot(b.y - py);
1706 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
1707 })
1708 .cloned()
1709 }
1710
1711 pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
1717 let mut rows = self.worn_rows();
1718 rows.extend(self.person_rows());
1719 for nc in self.nearby_containers() {
1720 rows.extend(nc.rows);
1721 }
1722 rows
1723 }
1724
1725 pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
1726 self.inventory_selectable_rows()
1727 .into_iter()
1728 .nth(self.inventory_menu_index)
1729 }
1730
1731 pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
1733 let cat = self
1734 .inventory_item_category(&row.stack.template_id)
1735 .unwrap_or("");
1736 let label = if cat == "key" {
1737 self.key_inventory_label(&row.stack)
1738 } else {
1739 row.stack
1740 .display_name
1741 .clone()
1742 .unwrap_or_else(|| row.stack.template_id.clone())
1743 };
1744 let hint: String = if row.is_equip_shell {
1745 " [worn — Enter to unequip]".into()
1746 } else if row.is_chest_shell {
1747 let (locked, lodging_note) = match &row.from {
1748 flatland_protocol::InventoryLocation::Placed { container_id } => {
1749 let locked = self
1750 .placed_containers
1751 .iter()
1752 .find(|c| c.id == *container_id)
1753 .map(|c| c.locked)
1754 .unwrap_or(false);
1755 let lodging_note = self
1756 .lodging_occupancy_label(container_id)
1757 .map(|who| format!(" [lodging: {who}]"))
1758 .unwrap_or_default();
1759 (locked, lodging_note)
1760 }
1761 _ => (false, String::new()),
1762 };
1763 if locked {
1764 format!(" [locked — Enter pick up · l unlock]{lodging_note}")
1765 } else {
1766 format!(" [Enter pick up · l lock]{lodging_note}")
1767 }
1768 } else if cat == "key" {
1769 self.key_inventory_hint(&row.stack)
1770 } else {
1771 match cat {
1772 "weapon" => " [weapon]".into(),
1773 "container" => " [bag/chest/belt]".into(),
1774 "lodging" => " [worker lodging]".into(),
1775 "armor" => " [armor]".into(),
1776 _ => String::new(),
1777 }
1778 };
1779 let qty = if row.stack.quantity > 1 {
1780 format!(" ×{}", row.stack.quantity)
1781 } else {
1782 String::new()
1783 };
1784 let mass = self.stack_mass(&row.stack);
1785 let mass_kg = (mass >= 0.05).then_some(mass);
1786 let mass_str = mass_kg
1787 .map(|m| format!(" {m:.1} kg"))
1788 .unwrap_or_default();
1789 let volume = self.container_volume_stats(row);
1790 let vol_str = self.container_volume_label(row);
1791
1792 let mut title = label.clone();
1793 if let Some(id) = row.stack.item_instance_id {
1794 let hex: String = id
1795 .as_simple()
1796 .to_string()
1797 .chars()
1798 .filter(|c| c.is_ascii_hexdigit())
1799 .collect();
1800 if hex.len() >= 4 {
1801 title.push_str(&format!(" #{}", &hex[hex.len() - 4..]));
1802 }
1803 }
1804 title.push_str(&qty);
1805 if row.is_equip_shell {
1806 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
1807 title.push_str(&format!(" ({})", body_slot_label(slot)));
1808 }
1809 }
1810
1811 InventoryRowView {
1812 depth: row.depth,
1813 text: format!("{label}{hint}{qty}{mass_str}{vol_str}"),
1814 title,
1815 mass_kg,
1816 volume,
1817 }
1818 }
1819
1820 pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
1823 let mut lines = Vec::new();
1824 let target = self.inventory_menu_index;
1825 let highlight = !self.show_move_picker;
1826 let mut global_idx = 0usize;
1827
1828 lines.push(InventoryBrowserLine::Section("— Worn —".into()));
1829 let worn = self.worn_rows();
1830 if worn.is_empty() {
1831 lines.push(InventoryBrowserLine::Hint(
1832 " (nothing equipped — wear a backpack/belt from \"On you\" below)".into(),
1833 ));
1834 } else {
1835 for row in &worn {
1836 if row.is_equip_shell {
1837 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
1838 lines.push(InventoryBrowserLine::SlotLabel(format!(
1839 " {}:",
1840 body_slot_label(slot)
1841 )));
1842 }
1843 }
1844 let view = self.format_inventory_row(row);
1845 lines.push(InventoryBrowserLine::Item {
1846 selectable_index: global_idx,
1847 selected: highlight && global_idx == target,
1848 depth: view.depth,
1849 text: view.text,
1850 title: view.title,
1851 mass_kg: view.mass_kg,
1852 volume: view.volume,
1853 });
1854 global_idx += 1;
1855 }
1856 }
1857
1858 lines.push(InventoryBrowserLine::Blank);
1859 lines.push(InventoryBrowserLine::Section(
1860 "— On you (loose, not worn) —".into(),
1861 ));
1862 let person = self.person_rows();
1863 if person.is_empty() {
1864 lines.push(InventoryBrowserLine::Hint(" (empty)".into()));
1865 } else {
1866 for row in &person {
1867 let view = self.format_inventory_row(row);
1868 lines.push(InventoryBrowserLine::Item {
1869 selectable_index: global_idx,
1870 selected: highlight && global_idx == target,
1871 depth: view.depth,
1872 text: view.text,
1873 title: view.title,
1874 mass_kg: view.mass_kg,
1875 volume: view.volume,
1876 });
1877 global_idx += 1;
1878 }
1879 }
1880
1881 let nearby = self.nearby_containers();
1882 if nearby.is_empty() {
1883 lines.push(InventoryBrowserLine::Blank);
1884 lines.push(InventoryBrowserLine::Section("— Nearby chests —".into()));
1885 lines.push(InventoryBrowserLine::Hint(
1886 " (none within reach — walk up to a chest)".into(),
1887 ));
1888 } else {
1889 for nc in &nearby {
1890 lines.push(InventoryBrowserLine::Blank);
1891 let lock_note = if nc.view.locked && nc.view.accessible {
1892 " unlocked with your key"
1893 } else if nc.view.locked {
1894 " locked"
1895 } else {
1896 ""
1897 };
1898 lines.push(InventoryBrowserLine::Section(format!(
1899 "— {} ({:.0}m away){lock_note} —",
1900 nc.view.display_name, nc.distance_m
1901 )));
1902 if !nc.view.accessible {
1903 lines.push(InventoryBrowserLine::Hint(
1904 " locked — need the matching key (l to try)".into(),
1905 ));
1906 } else if nc.rows.is_empty() {
1907 lines.push(InventoryBrowserLine::Hint(
1908 " (empty — select chest row above, m to move items in)".into(),
1909 ));
1910 } else {
1911 for row in &nc.rows {
1912 let view = self.format_inventory_row(row);
1913 lines.push(InventoryBrowserLine::Item {
1914 selectable_index: global_idx,
1915 selected: highlight && global_idx == target,
1916 depth: view.depth,
1917 text: view.text,
1918 title: view.title,
1919 mass_kg: view.mass_kg,
1920 volume: view.volume,
1921 });
1922 global_idx += 1;
1923 }
1924 }
1925 }
1926 }
1927 lines
1928 }
1929
1930 pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
1932 let mut opts = Vec::new();
1933 opts.push(MoveOption {
1934 label: "On your person (loose)".into(),
1935 kind: MoveOptionKind::PickupPlaced {
1936 container_id: container_id.to_string(),
1937 nest_location: flatland_protocol::InventoryLocation::Root,
1938 nest_parent_instance_id: None,
1939 },
1940 });
1941 for (slot, item) in &self.worn {
1942 if item.category.as_deref() != Some("container") {
1943 continue;
1944 }
1945 if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
1946 continue;
1947 }
1948 let Some(parent_id) = item.item_instance_id else {
1949 continue;
1950 };
1951 let shell_name = item
1952 .display_name
1953 .clone()
1954 .unwrap_or_else(|| item.template_id.clone());
1955 opts.push(MoveOption {
1956 label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
1957 kind: MoveOptionKind::PickupPlaced {
1958 container_id: container_id.to_string(),
1959 nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
1960 nest_parent_instance_id: Some(parent_id),
1961 },
1962 });
1963 Self::append_chest_pickup_nested(
1965 &mut opts,
1966 container_id,
1967 flatland_protocol::InventoryLocation::Worn { slot: *slot },
1968 item,
1969 &format!("in {shell_name}"),
1970 );
1971 }
1972 opts.push(MoveOption {
1973 label: "Cancel".into(),
1974 kind: MoveOptionKind::Cancel,
1975 });
1976 opts
1977 }
1978
1979 fn append_chest_pickup_nested(
1980 opts: &mut Vec<MoveOption>,
1981 container_id: &str,
1982 location: flatland_protocol::InventoryLocation,
1983 parent: &flatland_protocol::ItemStack,
1984 context: &str,
1985 ) {
1986 for child in &parent.contents {
1987 if child.category.as_deref() != Some("container") {
1988 continue;
1989 }
1990 if !Self::is_volume_container_stack(child) {
1991 continue;
1992 }
1993 if child.world_placeable == Some(true) {
1995 continue;
1996 }
1997 let Some(child_id) = child.item_instance_id else {
1998 continue;
1999 };
2000 let name = child
2001 .display_name
2002 .clone()
2003 .unwrap_or_else(|| child.template_id.clone());
2004 opts.push(MoveOption {
2005 label: format!("{name} ({context})"),
2006 kind: MoveOptionKind::PickupPlaced {
2007 container_id: container_id.to_string(),
2008 nest_location: location.clone(),
2009 nest_parent_instance_id: Some(child_id),
2010 },
2011 });
2012 Self::append_chest_pickup_nested(
2013 opts,
2014 container_id,
2015 location.clone(),
2016 child,
2017 &format!("in {name}"),
2018 );
2019 }
2020 }
2021
2022 pub fn move_destinations_for(
2024 &self,
2025 from: &flatland_protocol::InventoryLocation,
2026 from_parent_instance_id: Option<uuid::Uuid>,
2027 moving_instance_id: Option<uuid::Uuid>,
2028 moving_template_id: &str,
2029 ) -> Vec<MoveOption> {
2030 let mut opts = Vec::new();
2031 if *from != flatland_protocol::InventoryLocation::Root {
2032 opts.push(MoveOption {
2033 label: "On your person (loose)".into(),
2034 kind: MoveOptionKind::Move {
2035 location: flatland_protocol::InventoryLocation::Root,
2036 parent_instance_id: None,
2037 },
2038 });
2039 }
2040 for (slot, item) in &self.worn {
2041 if item.category.as_deref() != Some("container") {
2042 continue;
2043 }
2044 let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
2045 let shell_name = item
2046 .display_name
2047 .clone()
2048 .unwrap_or_else(|| item.template_id.clone());
2049
2050 if *slot != BodySlot::Waist
2052 && item.item_instance_id != moving_instance_id
2053 && Self::is_volume_container_stack(item)
2054 {
2055 Self::push_move_destination(
2056 &mut opts,
2057 format!("{shell_name} (worn {})", body_slot_label(*slot)),
2058 location.clone(),
2059 item.item_instance_id,
2060 from,
2061 from_parent_instance_id,
2062 );
2063 }
2064
2065 if *slot == BodySlot::Waist
2067 && Self::attaches_to_belt_loop(moving_template_id)
2068 && item.item_instance_id != moving_instance_id
2069 {
2070 Self::push_move_destination(
2071 &mut opts,
2072 format!("{shell_name} (belt loop)"),
2073 location.clone(),
2074 item.item_instance_id,
2075 from,
2076 from_parent_instance_id,
2077 );
2078 }
2079
2080 let context = if *slot == BodySlot::Waist {
2081 format!("on {shell_name}")
2082 } else {
2083 format!("in {shell_name}")
2084 };
2085 Self::append_nested_container_destinations(
2086 &mut opts,
2087 location,
2088 item,
2089 &context,
2090 from,
2091 from_parent_instance_id,
2092 moving_instance_id,
2093 );
2094 }
2095 for nc in self.nearby_containers() {
2096 if !nc.view.accessible {
2097 continue;
2098 }
2099 let location = flatland_protocol::InventoryLocation::Placed {
2100 container_id: nc.view.id.clone(),
2101 };
2102 Self::push_move_destination(
2103 &mut opts,
2104 format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
2105 location,
2106 nc.view.item_instance_id,
2107 from,
2108 from_parent_instance_id,
2109 );
2110 }
2111 let allow_drop = moving_instance_id
2112 .and_then(|id| self.stack_for_instance(id))
2113 .map(|stack| !self.key_drop_blocked(&stack))
2114 .unwrap_or(moving_template_id != KEY_TEMPLATE);
2115 if allow_drop {
2116 opts.push(MoveOption {
2117 label: "Drop on the ground".into(),
2118 kind: MoveOptionKind::Drop,
2119 });
2120 }
2121 opts.push(MoveOption {
2122 label: "Cancel".into(),
2123 kind: MoveOptionKind::Cancel,
2124 });
2125 opts
2126 }
2127
2128 fn is_same_container_dest(
2129 dest_location: &flatland_protocol::InventoryLocation,
2130 dest_parent: Option<uuid::Uuid>,
2131 from: &flatland_protocol::InventoryLocation,
2132 from_parent: Option<uuid::Uuid>,
2133 ) -> bool {
2134 dest_location == from && dest_parent == from_parent
2135 }
2136
2137 fn push_move_destination(
2138 opts: &mut Vec<MoveOption>,
2139 label: String,
2140 location: flatland_protocol::InventoryLocation,
2141 parent_instance_id: Option<uuid::Uuid>,
2142 from: &flatland_protocol::InventoryLocation,
2143 from_parent_instance_id: Option<uuid::Uuid>,
2144 ) {
2145 if Self::is_same_container_dest(
2146 &location,
2147 parent_instance_id,
2148 from,
2149 from_parent_instance_id,
2150 ) {
2151 return;
2152 }
2153 opts.push(MoveOption {
2154 label,
2155 kind: MoveOptionKind::Move {
2156 location,
2157 parent_instance_id,
2158 },
2159 });
2160 }
2161
2162 fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
2163 stack.capacity_volume.is_some_and(|c| c > 0.0)
2164 }
2165
2166 fn attaches_to_belt_loop(template_id: &str) -> bool {
2167 matches!(template_id, "leather_pouch" | "dimensional_pouch")
2168 }
2169
2170 fn append_nested_container_destinations(
2171 opts: &mut Vec<MoveOption>,
2172 location: flatland_protocol::InventoryLocation,
2173 container: &flatland_protocol::ItemStack,
2174 context: &str,
2175 from: &flatland_protocol::InventoryLocation,
2176 from_parent_instance_id: Option<uuid::Uuid>,
2177 moving_instance_id: Option<uuid::Uuid>,
2178 ) {
2179 for child in &container.contents {
2180 if Self::is_volume_container_stack(child)
2181 && child.item_instance_id != moving_instance_id
2182 {
2183 let name = child
2184 .display_name
2185 .clone()
2186 .unwrap_or_else(|| child.template_id.clone());
2187 Self::push_move_destination(
2188 opts,
2189 format!("{name} ({context})"),
2190 location.clone(),
2191 child.item_instance_id,
2192 from,
2193 from_parent_instance_id,
2194 );
2195 }
2196 let nested_context = format!(
2197 "in {}",
2198 child.display_name.as_deref().unwrap_or(&child.template_id)
2199 );
2200 Self::append_nested_container_destinations(
2201 opts,
2202 location.clone(),
2203 child,
2204 &nested_context,
2205 from,
2206 from_parent_instance_id,
2207 moving_instance_id,
2208 );
2209 }
2210 }
2211
2212 fn clamp_inventory_indices(&mut self) {
2213 let n = self.inventory_selectable_rows().len();
2214 self.inventory_menu_index = if n == 0 {
2215 0
2216 } else {
2217 self.inventory_menu_index.min(n - 1)
2218 };
2219 if let Some(picker) = &self.move_picker {
2220 let pn = picker.options.len();
2221 self.move_picker_index = if pn == 0 {
2222 0
2223 } else {
2224 self.move_picker_index.min(pn - 1)
2225 };
2226 }
2227 }
2228
2229 fn sync_interior_map_context(&mut self) {
2231 if self.effective_inside_building().is_none() {
2232 self.interior_map = None;
2233 }
2234 }
2235
2236 fn apply_snapshot_fields(
2237 &mut self,
2238 snapshot: &flatland_protocol::Snapshot,
2239 entity_id: EntityId,
2240 ) {
2241 self.tick = snapshot.tick;
2242 self.chunk_rev = snapshot.chunk_rev;
2243 self.content_rev = snapshot.content_rev;
2244 self.publish_rev = snapshot.publish_rev;
2245 self.resource_nodes = snapshot.resource_nodes.clone();
2246 self.ground_drops = snapshot.ground_drops.clone();
2247 self.placed_containers = snapshot.placed_containers.clone();
2248 self.world_width_m = snapshot.world_width_m;
2249 self.world_height_m = snapshot.world_height_m;
2250 self.world_clock = snapshot.world_clock;
2251 self.terrain_zones = snapshot.terrain_zones.clone();
2252 self.z_platforms = snapshot.z_platforms.clone();
2253 self.z_transitions = snapshot.z_transitions.clone();
2254 self.buildings = snapshot.buildings.clone();
2255 self.doors = snapshot.doors.clone();
2256 self.interior_map = snapshot.interior_map.clone();
2257 self.npcs = snapshot.npcs.clone();
2258 self.blueprints = snapshot.blueprints.clone();
2259 self.sync_inventory_from_stacks(&snapshot.inventory);
2260 self.player = snapshot
2261 .entities
2262 .iter()
2263 .find(|e| e.id == entity_id)
2264 .cloned();
2265 self.entities = snapshot.entities.clone();
2266 self.quest_log = snapshot.quest_log.clone();
2267 self.apply_hired_workers(snapshot.hired_workers.clone());
2268 self.interactables = snapshot.interactables.clone();
2269 self.ledger = snapshot.ledger.clone();
2270 self.career = snapshot.career.clone();
2271 self.sync_interior_map_context();
2272 }
2273
2274 fn refresh_inventory_ui(&mut self) {
2278 if let Some(picker) = &self.move_picker {
2279 let instance_id = picker.item_instance_id;
2280 let still_exists = self
2281 .inventory_selectable_rows()
2282 .iter()
2283 .any(|r| r.stack.item_instance_id == Some(instance_id));
2284 if !still_exists {
2285 self.move_picker = None;
2286 self.show_move_picker = false;
2287 }
2288 }
2289 if let Some(picker) = &self.destroy_picker {
2290 let instance_id = picker.item_instance_id;
2291 let still_exists = self
2292 .inventory_selectable_rows()
2293 .iter()
2294 .any(|r| r.stack.item_instance_id == Some(instance_id));
2295 if !still_exists {
2296 self.destroy_picker = None;
2297 self.show_destroy_picker = false;
2298 self.destroy_confirm_pending = false;
2299 }
2300 }
2301 self.clamp_inventory_indices();
2302 }
2303
2304 fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
2310 let selected_id = self
2311 .hired_workers
2312 .get(self.workers_menu_index)
2313 .map(|w| w.instance_id.clone());
2314 workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
2315 let now = Instant::now();
2316 let mut next_display = BTreeMap::new();
2317 for w in &workers {
2318 let mut sticky = self
2319 .worker_step_display
2320 .remove(&w.instance_id)
2321 .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
2322 sticky.observe(&w.step_label, now);
2323 next_display.insert(w.instance_id.clone(), sticky);
2324 }
2325 self.worker_step_display = next_display;
2326 self.hired_workers = workers;
2327 if let Some(id) = selected_id {
2328 if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
2329 self.workers_menu_index = idx;
2330 return;
2331 }
2332 }
2333 if self.workers_menu_index >= self.hired_workers.len() {
2334 self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
2335 }
2336 }
2337
2338 pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
2340 self.worker_step_display
2341 .get(worker_instance_id)
2342 .map(|s| s.shown.as_str())
2343 .or_else(|| {
2344 self.hired_workers
2345 .iter()
2346 .find(|w| w.instance_id == worker_instance_id)
2347 .map(|w| w.step_label.as_str())
2348 })
2349 .unwrap_or("")
2350 }
2351
2352 fn apply_combat_hud(&mut self, combat: &CombatHud) {
2353 self.in_combat = combat.in_combat;
2354 self.auto_attack = combat.auto_attack;
2355 self.combat_has_los = combat.has_los;
2356 self.attack_cd_ticks = combat.attack_cd_ticks;
2357 self.gcd_ticks = combat.gcd_ticks;
2358 self.weapon_ability_id = combat.ability_id.clone();
2359 self.mainhand_template_id = combat.mainhand_template_id.clone();
2360 self.mainhand_label = combat.mainhand_label.clone();
2361 self.offhand_template_id = combat.offhand_template_id.clone();
2362 self.offhand_label = combat.offhand_label.clone();
2363 self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
2364 1
2365 } else {
2366 combat.mainhand_hand_slots
2367 };
2368 self.defense = combat.defense.clone();
2369 self.worn = combat.worn.iter().cloned().collect();
2370 self.carry_mass = combat.carry_mass;
2371 self.carry_mass_max = combat.carry_mass_max;
2372 self.encumbrance = combat.encumbrance;
2373 self.cast_progress = combat.cast.clone();
2374 self.ability_cooldowns = combat.ability_cooldowns.clone();
2375 self.blocking_active = combat.blocking_active;
2376 self.max_target_slots = combat.max_target_slots.max(1);
2377 self.combat_slots = combat.slots.clone();
2378 self.rotation_presets = combat.rotation_presets.clone();
2379 self.keychain_stacks = combat.keychain.clone();
2380 self.combat_target_detail = combat.target.clone();
2381 self.combat_target = combat.target_entity_id;
2382 if combat.progression_xp_base > 0.0 {
2383 self.progression_curve = Some(flatland_protocol::ProgressionCurve {
2384 baseline_display: combat.progression_baseline,
2385 xp_base: combat.progression_xp_base,
2386 xp_growth: combat.progression_xp_growth,
2387 });
2388 }
2389 if let Some(xp) = &combat.progression_xp {
2390 if let Some(player) = &mut self.player {
2391 player.progression_xp = Some(xp.clone());
2392 if let Some(attrs) = combat.attributes {
2393 player.attributes = Some(attrs);
2394 }
2395 if let Some(skills) = &combat.skills {
2396 player.skills = Some(skills.clone());
2397 }
2398 }
2399 }
2400 if let Some(label) = &combat.target_label {
2401 self.combat_target_label = Some(label.clone());
2402 } else if let Some(id) = combat.target_entity_id {
2403 self.combat_target_label = self
2404 .entities
2405 .iter()
2406 .find(|e| e.id == id)
2407 .map(|e| e.label.clone())
2408 .or_else(|| self.combat_target_label.clone());
2409 }
2410 self.refresh_inventory_ui();
2411 }
2412
2413 pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
2415 self.combat_slots
2416 .iter()
2417 .find(|s| s.slot_index == slot)
2418 .and_then(|s| s.target_entity_id)
2419 .or_else(|| if slot == 1 { self.combat_target } else { None })
2420 }
2421
2422 pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
2424 self.combat_candidates()
2425 }
2426
2427 pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
2429 let (px, py) = self.player_position();
2430 let dist = |id: EntityId| {
2431 self.entities
2432 .iter()
2433 .find(|e| e.id == id)
2434 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
2435 .unwrap_or(f32::MAX)
2436 };
2437
2438 let mut allies = Vec::new();
2439 if let Some(me) = self.player.as_ref() {
2441 let alive = me
2442 .vitals
2443 .as_ref()
2444 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
2445 .unwrap_or(true);
2446 if alive {
2447 allies.push((self.entity_id, "Yourself".into()));
2448 }
2449 }
2450 for entity in &self.entities {
2451 if entity.id == self.entity_id {
2452 continue;
2453 }
2454 if entity.vitals.is_some() {
2455 let alive = entity
2456 .vitals
2457 .as_ref()
2458 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
2459 .unwrap_or(true);
2460 if alive {
2461 allies.push((entity.id, entity.label.clone()));
2462 }
2463 }
2464 }
2465 allies.sort_by(|(a, _), (b, _)| {
2466 if *a == self.entity_id {
2467 return std::cmp::Ordering::Less;
2468 }
2469 if *b == self.entity_id {
2470 return std::cmp::Ordering::Greater;
2471 }
2472 dist(*a)
2473 .partial_cmp(&dist(*b))
2474 .unwrap_or(std::cmp::Ordering::Equal)
2475 });
2476
2477 let mut monsters = self.combat_candidates();
2478 monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
2479 allies.into_iter().chain(monsters).collect()
2480 }
2481
2482 fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
2483 match slot_index {
2484 2 => self.t2_candidates(),
2485 _ => self.t1_candidates(),
2486 }
2487 }
2488
2489 pub fn pick_combat_target_at(
2491 &self,
2492 wx: f32,
2493 wy: f32,
2494 slot_index: u8,
2495 radius_m: f32,
2496 ) -> Option<(EntityId, String)> {
2497 let mut best: Option<(f32, EntityId, String)> = None;
2498 for (id, label) in self.candidates_for_slot(slot_index) {
2499 let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
2500 if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
2502 let d = distance(wx, wy, npc.x, npc.y);
2503 if d <= radius_m {
2504 best = match best {
2505 Some((bd, _, _)) if bd <= d => best,
2506 _ => Some((d, id, label)),
2507 };
2508 }
2509 }
2510 continue;
2511 };
2512 let d = distance(
2513 wx,
2514 wy,
2515 entity.transform.position.x,
2516 entity.transform.position.y,
2517 );
2518 if d <= radius_m {
2519 best = match best {
2520 Some((bd, _, _)) if bd <= d => best,
2521 _ => Some((d, id, label)),
2522 };
2523 }
2524 }
2525 best.map(|(_, id, label)| (id, label))
2526 }
2527
2528 pub(crate) fn restore_from_welcome(
2530 &mut self,
2531 session_id: SessionId,
2532 entity_id: EntityId,
2533 snapshot: &flatland_protocol::Snapshot,
2534 ) {
2535 self.clear_harvest_state();
2536 self.disconnect_reason = None;
2537 self.show_stats = false;
2538 self.show_craft_menu = false;
2539 self.show_shop_menu = false;
2540 self.shop_catalog = None;
2541 self.show_inventory_menu = false;
2542 self.session_id = session_id;
2543 self.entity_id = entity_id;
2544 self.connected = true;
2545 self.apply_snapshot_fields(snapshot, entity_id);
2546 if let Some(combat) = &snapshot.combat {
2547 self.apply_combat_hud(combat);
2548 let stacks = self.inventory_stacks.clone();
2549 self.sync_inventory_from_stacks(&stacks);
2550 }
2551 }
2552
2553 fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
2554 self.tick = delta.tick;
2555 self.world_clock = delta.world_clock;
2556
2557 if delta.entities.is_empty() {
2559 if let Some(combat) = &delta.combat {
2560 self.apply_combat_hud(combat);
2561 let stacks = self.inventory_stacks.clone();
2562 self.sync_inventory_from_stacks(&stacks);
2563 }
2564 return;
2565 }
2566 if !delta.buildings.is_empty() {
2567 self.buildings = delta.buildings.clone();
2568 }
2569 if !delta.blueprints.is_empty() {
2570 self.blueprints = delta.blueprints.clone();
2571 }
2572 self.sync_inventory_from_stacks(&delta.inventory);
2573
2574 if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
2575 self.player = Some(updated.clone());
2576 }
2577 self.entities = delta.entities.clone();
2578 if self.player.is_none() {
2579 self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
2580 }
2581
2582 self.sync_interior_map_context();
2583
2584 if !delta.resource_nodes.is_empty() {
2587 self.resource_nodes = delta.resource_nodes.clone();
2588 }
2589 if self
2590 .player
2591 .as_ref()
2592 .is_none_or(|p| p.inside_building.is_none())
2593 {
2594 self.ground_drops = delta.ground_drops.clone();
2595 self.placed_containers = delta.placed_containers.clone();
2596 }
2597 if !delta.doors.is_empty() {
2598 self.doors = delta.doors.clone();
2599 }
2600 if self.effective_inside_building().is_some() {
2601 if let Some(map) = &delta.interior_map {
2602 self.interior_map = Some(map.clone());
2603 }
2604 } else {
2605 self.interior_map = None;
2606 }
2607 if !delta.npcs.is_empty() {
2608 self.npcs = delta.npcs.clone();
2609 }
2610 if !delta.quest_log.is_empty() {
2611 self.quest_log = delta.quest_log.clone();
2612 }
2613 self.apply_hired_workers(delta.hired_workers.clone());
2614 if !delta.interactables.is_empty() {
2615 self.interactables = delta.interactables.clone();
2616 }
2617 if delta.ledger.is_some() {
2618 self.ledger = delta.ledger.clone();
2619 }
2620 if delta.career.is_some() {
2621 self.career = delta.career.clone();
2622 }
2623 if let Some(combat) = &delta.combat {
2624 self.apply_combat_hud(combat);
2625 let stacks = self.inventory_stacks.clone();
2626 self.sync_inventory_from_stacks(&stacks);
2627 } else {
2628 self.refresh_inventory_ui();
2629 }
2630 }
2631
2632 pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
2634 let (px, py) = self.player_position();
2635 let mut out = Vec::new();
2636 for npc in &self.npcs {
2637 let Some(eid) = npc.entity_id else {
2638 continue;
2639 };
2640 let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
2641 let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
2642 if alive && has_hp {
2643 out.push((eid, npc.label.clone()));
2644 }
2645 }
2646 out.sort_by(|(a_id, a_label), (b_id, b_label)| {
2647 let dist = |id: EntityId| {
2648 self.entities
2649 .iter()
2650 .find(|e| e.id == id)
2651 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
2652 .unwrap_or(f32::MAX)
2653 };
2654 dist(*a_id)
2655 .partial_cmp(&dist(*b_id))
2656 .unwrap_or(std::cmp::Ordering::Equal)
2657 .then_with(|| a_label.cmp(b_label))
2658 .then_with(|| a_id.cmp(b_id))
2659 });
2660 out
2661 }
2662
2663 pub fn refresh_combat_target_label(&mut self) {
2664 let Some(id) = self.combat_target else {
2665 return;
2666 };
2667 if let Some((_, label)) = self
2668 .combat_candidates()
2669 .into_iter()
2670 .find(|(eid, _)| *eid == id)
2671 {
2672 self.combat_target_label = Some(label);
2673 } else if let Some(label) = self
2674 .entities
2675 .iter()
2676 .find(|e| e.id == id)
2677 .map(|e| e.label.clone())
2678 {
2679 self.combat_target_label = Some(label);
2680 }
2681 }
2682
2683 pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
2684 self.quest_log
2685 .iter()
2686 .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
2687 .collect()
2688 }
2689
2690 pub fn has_worker_lodging(&self) -> bool {
2692 self.free_worker_lodging_slots() > 0
2693 }
2694
2695 pub fn free_worker_lodging_slots(&self) -> i64 {
2697 let slots: u32 = self
2698 .placed_containers
2699 .iter()
2700 .filter(|c| match (self.character_id, c.owner_character_id) {
2701 (Some(me), Some(owner)) => me == owner,
2702 (Some(_), None) => false,
2703 (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
2704 })
2705 .map(|c| c.worker_lodging_capacity.unwrap_or(0))
2706 .sum();
2707 let used = self.hired_workers.len() as u32;
2708 slots as i64 - used as i64
2709 }
2710
2711 pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
2713 let mut names: Vec<String> = self
2714 .hired_workers
2715 .iter()
2716 .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
2717 .map(|w| w.label.clone())
2718 .collect();
2719 names.sort();
2720 names
2721 }
2722
2723 pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
2725 let is_lodging = self
2726 .placed_containers
2727 .iter()
2728 .find(|c| c.id == container_id)
2729 .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
2730 if !is_lodging {
2731 return None;
2732 }
2733 let names = self.lodging_occupant_labels(container_id);
2734 Some(if names.is_empty() {
2735 "vacant".into()
2736 } else {
2737 names.join(", ")
2738 })
2739 }
2740
2741 pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
2742 self.quest_log
2743 .iter()
2744 .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
2745 .or_else(|| {
2746 self.quest_log
2747 .iter()
2748 .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
2749 })
2750 }
2751
2752 pub fn nearest_interact_target(&self) -> Option<String> {
2754 let (px, py) = self.player_position();
2755 let inside = self.effective_inside_building();
2756
2757 #[derive(Clone, Copy, PartialEq, Eq)]
2758 enum Kind {
2759 Npc,
2760 QuestBoard,
2761 ExitDoor,
2762 EnterDoor,
2763 Well,
2764 Water,
2765 }
2766
2767 fn kind_priority(kind: Kind) -> u8 {
2768 match kind {
2769 Kind::Npc => 0,
2770 Kind::QuestBoard => 1,
2771 Kind::ExitDoor => 2,
2772 Kind::EnterDoor => 3,
2773 Kind::Well => 4,
2774 Kind::Water => 5,
2775 }
2776 }
2777
2778 let mut best: Option<(f32, Kind, String)> = None;
2779
2780 let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
2781 if dist > max {
2782 return;
2783 }
2784 let replace = match best {
2785 None => true,
2786 Some((bd, _bk, _)) if dist < bd - 0.05 => true,
2787 Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
2788 kind_priority(kind) < kind_priority(bk)
2789 }
2790 _ => false,
2791 };
2792 if replace {
2793 best = Some((dist, kind, id));
2794 }
2795 };
2796
2797 for npc in &self.npcs {
2798 consider(
2799 distance(px, py, npc.x, npc.y),
2800 INTERACTION_RADIUS_M,
2801 Kind::Npc,
2802 npc.id.clone(),
2803 );
2804 }
2805
2806 for door in &self.doors {
2807 if let Some(ref bid) = inside {
2808 if door.building_id != *bid {
2809 continue;
2810 }
2811 let is_exit = door.portal.is_some();
2812 let max = if is_exit {
2813 INTERACTION_RADIUS_M
2814 } else {
2815 DOOR_INTERACTION_RADIUS_M
2816 };
2817 let kind = if is_exit {
2818 Kind::ExitDoor
2819 } else {
2820 Kind::EnterDoor
2821 };
2822 consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
2823 continue;
2824 }
2825 consider(
2826 distance(px, py, door.x, door.y),
2827 DOOR_INTERACTION_RADIUS_M,
2828 Kind::EnterDoor,
2829 door.id.clone(),
2830 );
2831 }
2832
2833 if inside.is_none() {
2834 for inter in &self.interactables {
2835 if inter.kind == "quest_board" {
2836 consider(
2837 distance(px, py, inter.x, inter.y),
2838 QUEST_BOARD_INTERACTION_RADIUS_M,
2839 Kind::QuestBoard,
2840 inter.id.clone(),
2841 );
2842 }
2843 }
2844 for building in &self.buildings {
2845 if !building.tags.iter().any(|t| t == "well") {
2846 continue;
2847 }
2848 consider(
2849 distance(px, py, building.x, building.y),
2850 INTERACTION_RADIUS_M,
2851 Kind::Well,
2852 building.id.clone(),
2853 );
2854 }
2855 if self.in_shallow_water() {
2856 consider(
2857 0.0,
2858 INTERACTION_RADIUS_M,
2859 Kind::Water,
2860 "water_source".into(),
2861 );
2862 }
2863 }
2864
2865 best.map(|(_, _, id)| id)
2866 }
2867
2868 pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
2870 if self.effective_inside_building().is_some() {
2871 return None;
2872 }
2873 let (px, py) = self.player_position();
2874 self.interactables
2875 .iter()
2876 .filter(|i| i.kind == "quest_board")
2877 .map(|i| {
2878 let label = if i.label.is_empty() {
2879 "Quest board".to_string()
2880 } else {
2881 i.label.clone()
2882 };
2883 (label, distance(px, py, i.x, i.y))
2884 })
2885 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
2886 }
2887
2888 pub fn template_display_name(&self, template_id: &str) -> String {
2890 self.inventory_hints
2891 .get(template_id)
2892 .map(|h| h.display_name.clone())
2893 .filter(|n| !n.is_empty())
2894 .unwrap_or_else(|| humanize_template_id(template_id))
2895 }
2896
2897 pub fn placed_container_public_label(
2899 &self,
2900 c: &flatland_protocol::PlacedContainerView,
2901 ) -> String {
2902 let is_owner = match (self.character_id, c.owner_character_id) {
2903 (Some(me), Some(owner)) => me == owner,
2904 _ => false,
2905 };
2906 if is_owner {
2907 c.display_name.clone()
2908 } else {
2909 self.template_display_name(&c.template_id)
2910 }
2911 }
2912
2913 pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
2915 let mut out = Vec::new();
2916 for stack in &self.inventory_stacks {
2917 if stack.template_id == KEY_TEMPLATE {
2918 out.push(KeychainEntry {
2919 stack: stack.clone(),
2920 stowed: false,
2921 });
2922 }
2923 }
2924 for stack in &self.keychain_stacks {
2925 if stack.template_id == KEY_TEMPLATE {
2926 out.push(KeychainEntry {
2927 stack: stack.clone(),
2928 stowed: true,
2929 });
2930 }
2931 }
2932 out
2933 }
2934
2935 pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
2937 if stack.template_id != KEY_TEMPLATE {
2938 return None;
2939 }
2940 if let Some(name) = stack
2941 .props
2942 .get(PROP_OPENS_CONTAINER_NAME)
2943 .filter(|n| !n.is_empty())
2944 {
2945 return Some(name.clone());
2946 }
2947 let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
2948 self.container_name_for_lock_id(opens)
2949 }
2950
2951 pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
2953 if stack.template_id == KEY_TEMPLATE {
2954 self.template_display_name(KEY_TEMPLATE)
2955 } else {
2956 stack
2957 .display_name
2958 .clone()
2959 .unwrap_or_else(|| stack.template_id.clone())
2960 }
2961 }
2962
2963 pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
2965 if stack.template_id != KEY_TEMPLATE {
2966 return String::new();
2967 }
2968 match self.key_pair_chest_label(stack) {
2969 Some(chest) if self.key_drop_blocked(stack) => {
2970 format!(" [key for {chest} — can't drop while locked]")
2971 }
2972 Some(chest) => format!(" [key for {chest}]"),
2973 None => " [key — unpaired]".into(),
2974 }
2975 }
2976
2977 pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
2979 for c in &self.placed_containers {
2980 if c.lock_id.as_deref() == Some(lock) {
2981 return Some(c.display_name.clone());
2982 }
2983 }
2984 Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
2985 self.worn
2986 .values()
2987 .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
2988 })
2989 }
2990
2991 pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
2993 if stack.template_id != KEY_TEMPLATE {
2994 return false;
2995 }
2996 let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
2997 return false;
2998 };
2999 for c in &self.placed_containers {
3000 if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
3001 return true;
3002 }
3003 }
3004 if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
3005 return true;
3006 }
3007 self.worn
3008 .values()
3009 .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
3010 }
3011
3012 fn container_name_in_stacks(
3013 stacks: &[flatland_protocol::ItemStack],
3014 lock: &str,
3015 ) -> Option<String> {
3016 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
3017 for s in stacks {
3018 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
3019 return Some(GameState::stack_container_label(s));
3020 }
3021 if let Some(name) = walk(&s.contents, lock) {
3022 return Some(name);
3023 }
3024 }
3025 None
3026 }
3027 walk(stacks, lock)
3028 }
3029
3030 fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
3031 stack
3032 .props
3033 .get(PROP_CUSTOM_NAME)
3034 .cloned()
3035 .or_else(|| stack.display_name.clone())
3036 .unwrap_or_else(|| stack.template_id.clone())
3037 }
3038
3039 fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
3040 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
3041 for s in stacks {
3042 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
3043 return true;
3044 }
3045 if walk(&s.contents, lock) {
3046 return true;
3047 }
3048 }
3049 false
3050 }
3051 walk(stacks, lock)
3052 }
3053
3054 fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
3055 if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
3056 return Some(stack.clone());
3057 }
3058 for worn in self.worn.values() {
3059 if worn.item_instance_id == Some(instance_id) {
3060 return Some(worn.clone());
3061 }
3062 if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
3063 return Some(stack.clone());
3064 }
3065 }
3066 None
3067 }
3068
3069 pub fn location_context_lines(&self) -> Vec<ContextLine> {
3071 let (px, py) = self.player_position();
3072 let inside = self.effective_inside_building();
3073 let mut lines = Vec::new();
3074
3075 if let Some(kind) = self.terrain_at(px, py) {
3076 lines.push(ContextLine {
3077 on_top: true,
3078 text: format!("Terrain: {}", terrain_kind_label(kind)),
3079 });
3080 }
3081
3082 if let Some(id) = inside.as_ref() {
3083 if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
3084 lines.push(ContextLine {
3085 on_top: true,
3086 text: format!("Inside: {}", b.label),
3087 });
3088 }
3089 }
3090
3091 let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
3092
3093 for node in &self.resource_nodes {
3094 let dist = distance(px, py, node.x, node.y);
3095 if dist > NEARBY_SCAN_M {
3096 continue;
3097 }
3098 let on_top = dist <= ON_TOP_RADIUS_M;
3099 let state = match node.state {
3100 flatland_protocol::ResourceNodeState::Available => " — f harvest",
3101 flatland_protocol::ResourceNodeState::Harvesting => " (being harvested)",
3102 flatland_protocol::ResourceNodeState::Cooldown => " (depleted)",
3103 };
3104 let prefix = if on_top { "On" } else { "Near" };
3105 nearby.push((
3106 dist,
3107 ContextLine {
3108 on_top,
3109 text: format!("{prefix}: {} ({dist:.1}m){state}", node.label),
3110 },
3111 ));
3112 }
3113
3114 for drop in &self.ground_drops {
3115 let dist = distance(px, py, drop.x, drop.y);
3116 if dist > INTERACTION_RADIUS_M {
3117 continue;
3118 }
3119 let on_top = dist <= ON_TOP_RADIUS_M;
3120 let name = self.template_display_name(&drop.template_id);
3121 let prefix = if on_top { "On" } else { "Near" };
3122 let qty = if drop.quantity > 1 {
3123 format!(" ×{}", drop.quantity)
3124 } else {
3125 String::new()
3126 };
3127 nearby.push((
3128 dist,
3129 ContextLine {
3130 on_top,
3131 text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
3132 },
3133 ));
3134 }
3135
3136 for c in &self.placed_containers {
3137 let dist = distance(px, py, c.x, c.y);
3138 if dist > CONTAINER_RANGE_M {
3139 continue;
3140 }
3141 let on_top = dist <= ON_TOP_RADIUS_M;
3142 let name = self.placed_container_public_label(c);
3143 let lock = if c.locked { " [locked]" } else { "" };
3144 let prefix = if on_top { "On" } else { "Near" };
3145 nearby.push((
3146 dist,
3147 ContextLine {
3148 on_top,
3149 text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
3150 },
3151 ));
3152 }
3153
3154 for npc in &self.npcs {
3155 let dist = distance(px, py, npc.x, npc.y);
3156 if dist > NEARBY_SCAN_M {
3157 continue;
3158 }
3159 let on_top = dist <= ON_TOP_RADIUS_M;
3160 let prefix = if on_top { "On" } else { "Near" };
3161 nearby.push((
3162 dist,
3163 ContextLine {
3164 on_top,
3165 text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
3166 },
3167 ));
3168 }
3169
3170 for door in &self.doors {
3171 let dist = distance(px, py, door.x, door.y);
3172 if dist > DOOR_INTERACTION_RADIUS_M {
3173 continue;
3174 }
3175 let building = self
3176 .buildings
3177 .iter()
3178 .find(|b| b.id == door.building_id)
3179 .map(|b| b.label.as_str())
3180 .unwrap_or(door.building_id.as_str());
3181 let action = if inside.is_some() && door.portal.is_some() {
3182 "exit"
3183 } else {
3184 "enter"
3185 };
3186 nearby.push((
3187 dist,
3188 ContextLine {
3189 on_top: dist <= ON_TOP_RADIUS_M,
3190 text: format!("{building} door ({dist:.1}m) — f {action}"),
3191 },
3192 ));
3193 }
3194
3195 if inside.is_none() {
3196 for inter in &self.interactables {
3197 if inter.kind != "quest_board" {
3198 continue;
3199 }
3200 let dist = distance(px, py, inter.x, inter.y);
3201 if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
3202 continue;
3203 }
3204 let on_top = dist <= ON_TOP_RADIUS_M;
3205 let prefix = if on_top { "On" } else { "Near" };
3206 let label = if inter.label.is_empty() {
3207 "Quest board".to_string()
3208 } else {
3209 inter.label.clone()
3210 };
3211 nearby.push((
3212 dist,
3213 ContextLine {
3214 on_top,
3215 text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
3216 },
3217 ));
3218 }
3219 }
3220
3221 if self.in_shallow_water() {
3222 let already = self
3223 .terrain_at(px, py)
3224 .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
3225 if !already {
3226 nearby.push((
3227 0.0,
3228 ContextLine {
3229 on_top: true,
3230 text: "Shallow water — f fill bottle".into(),
3231 },
3232 ));
3233 } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
3234 line.text.push_str(" — f fill bottle");
3235 }
3236 }
3237
3238 for entity in &self.entities {
3239 if entity.id == self.entity_id {
3240 continue;
3241 }
3242 let dist = distance(
3243 px,
3244 py,
3245 entity.transform.position.x,
3246 entity.transform.position.y,
3247 );
3248 if dist > NEARBY_SCAN_M {
3249 continue;
3250 }
3251 let label = if entity.label.is_empty() {
3252 format!("entity {}", entity.id)
3253 } else {
3254 entity.label.clone()
3255 };
3256 nearby.push((
3257 dist,
3258 ContextLine {
3259 on_top: dist <= ON_TOP_RADIUS_M,
3260 text: format!("Near: {label} ({dist:.1}m)"),
3261 },
3262 ));
3263 }
3264
3265 nearby.sort_by(|a, b| {
3266 a.0.partial_cmp(&b.0)
3267 .unwrap_or(std::cmp::Ordering::Equal)
3268 .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
3269 });
3270 lines.extend(nearby.into_iter().map(|(_, l)| l));
3271
3272 if lines.is_empty() {
3273 lines.push(ContextLine {
3274 on_top: false,
3275 text: "(nothing notable nearby)".into(),
3276 });
3277 }
3278
3279 lines
3280 }
3281}
3282
3283#[derive(Debug, Clone)]
3285pub struct ContextLine {
3286 pub on_top: bool,
3287 pub text: String,
3288}
3289
3290const ON_TOP_RADIUS_M: f32 = 0.65;
3291const NEARBY_SCAN_M: f32 = 5.0;
3292
3293fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
3294 use flatland_protocol::TerrainKindView;
3295 match kind {
3296 TerrainKindView::Grass => "Grass",
3297 TerrainKindView::Hill => "Hills",
3298 TerrainKindView::Bog => "Bog",
3299 TerrainKindView::ShallowWater => "Shallow water",
3300 TerrainKindView::DeepWater => "Deep water",
3301 TerrainKindView::Trail => "Trail",
3302 TerrainKindView::Rock => "Rock",
3303 }
3304}
3305
3306fn humanize_template_id(template_id: &str) -> String {
3307 template_id
3308 .split('_')
3309 .map(|word| {
3310 let mut chars = word.chars();
3311 match chars.next() {
3312 None => String::new(),
3313 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
3314 }
3315 })
3316 .collect::<Vec<_>>()
3317 .join(" ")
3318}
3319
3320const HARVEST_RANGE_M: f32 = 1.5;
3322
3323pub struct GameClient<S: PlayConnection> {
3324 session: S,
3325 seq: Seq,
3326 pub state: GameState,
3327 last_move_forward: f32,
3328 last_move_strafe: f32,
3329}
3330
3331impl<S: PlayConnection> GameClient<S> {
3332 pub fn new(session: S) -> Self {
3333 let session_id = session.session_id();
3334 let entity_id = session.entity_id();
3335 Self {
3336 session,
3337 seq: 0,
3338 last_move_forward: 0.0,
3339 last_move_strafe: 0.0,
3340 state: GameState {
3341 session_id,
3342 entity_id,
3343 character_id: None,
3344 tick: 0,
3345 chunk_rev: 0,
3346 content_rev: 0,
3347 publish_rev: 0,
3348 entities: Vec::new(),
3349 player: None,
3350 resource_nodes: Vec::new(),
3351 ground_drops: Vec::new(),
3352 placed_containers: Vec::new(),
3353 buildings: Vec::new(),
3354 doors: Vec::new(),
3355 interior_map: None,
3356 npcs: Vec::new(),
3357 blueprints: Vec::new(),
3358 world_width_m: 0.0,
3359 world_height_m: 0.0,
3360 terrain_zones: Vec::new(),
3361 z_platforms: Vec::new(),
3362 z_transitions: Vec::new(),
3363 world_clock: flatland_protocol::WorldClock::default(),
3364 inventory: std::collections::HashMap::new(),
3365 inventory_hints: std::collections::HashMap::new(),
3366 logs: VecDeque::new(),
3367 intents_sent: 0,
3368 ticks_received: 0,
3369 connected: false,
3370 disconnect_reason: None,
3371 show_stats: false,
3372 show_equip_menu: false,
3373 equip_menu_index: 0,
3374 show_craft_menu: false,
3375 craft_menu_index: 0,
3376 craft_batch_quantity: 1,
3377 show_shop_menu: false,
3378 shop_catalog: None,
3379 shop_tab: ShopTab::default(),
3380 shop_menu_index: 0,
3381 shop_quantity: 1,
3382 shop_trade_log: VecDeque::new(),
3383 show_npc_verb_menu: false,
3384 npc_verb_target: None,
3385 npc_verb_index: 0,
3386 show_npc_chat: false,
3387 npc_chat: None,
3388 show_inventory_menu: false,
3389 inventory_menu_index: 0,
3390 show_move_picker: false,
3391 show_rename_prompt: false,
3392 show_worker_rename: false,
3393 rename_buffer: String::new(),
3394 move_picker_index: 0,
3395 move_picker: None,
3396 show_destroy_picker: false,
3397 destroy_confirm_pending: false,
3398 destroy_picker: None,
3399 combat_target: None,
3400 combat_target_label: None,
3401 in_combat: false,
3402 auto_attack: true,
3403 combat_has_los: false,
3404 attack_cd_ticks: 0,
3405 gcd_ticks: 0,
3406 weapon_ability_id: "unarmed".into(),
3407 mainhand_template_id: None,
3408 mainhand_label: None,
3409 offhand_template_id: None,
3410 offhand_label: None,
3411 mainhand_hand_slots: 1,
3412 defense: None,
3413 worn: BTreeMap::new(),
3414 carry_mass: 0.0,
3415 carry_mass_max: 0.0,
3416 encumbrance: flatland_protocol::EncumbranceState::Light,
3417 inventory_stacks: Vec::new(),
3418 keychain_stacks: Vec::new(),
3419 combat_target_detail: None,
3420 cast_progress: None,
3421 ability_cooldowns: Vec::new(),
3422 blocking_active: false,
3423 max_target_slots: 1,
3424 combat_slots: Vec::new(),
3425 rotation_presets: Vec::new(),
3426 show_loadout_menu: false,
3427 show_keychain_menu: false,
3428 keychain_menu_index: 0,
3429 show_rotation_editor: false,
3430 loadout_menu_index: 0,
3431 rotation_editor: RotationEditorState::default(),
3432 harvest_in_progress: false,
3433 harvest_started_at: None,
3434 pending_craft_ack: None,
3435 pending_worker_job_ack: None,
3436 quest_log: Vec::new(),
3437 interactables: Vec::new(),
3438 ledger: None,
3439 career: None,
3440 character_sheet_tab: CharacterSheetTab::Character,
3441 ledger_period: LedgerPeriod::Day,
3442 show_quest_offer: false,
3443 pending_quest_offer: None,
3444 show_quest_menu: false,
3445 quest_menu_index: 0,
3446 quest_withdraw_confirm: false,
3447 hired_workers: Vec::new(),
3448 show_workers_menu: false,
3449 workers_menu_index: 0,
3450 workers_menu_compact: false,
3451 worker_step_display: BTreeMap::new(),
3452 show_worker_give_picker: false,
3453 worker_give_picker_index: 0,
3454 worker_give_picker: None,
3455 show_worker_give_target_picker: false,
3456 worker_give_target_picker_index: 0,
3457 worker_give_target_picker: None,
3458 show_worker_take_picker: false,
3459 worker_take_picker_index: 0,
3460 worker_take_picker: None,
3461 show_worker_teach_picker: false,
3462 worker_teach_picker_index: 0,
3463 worker_teach_picker: None,
3464 worker_route_editor: None,
3465 progression_curve: None,
3466 },
3467 }
3468 }
3469
3470 pub fn entity_id(&self) -> EntityId {
3471 self.state.entity_id
3472 }
3473
3474 pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
3475 if self.state.connected {
3476 return Ok(());
3477 }
3478
3479 loop {
3480 match self.session.next_event().await {
3481 Some(SessionEvent::Welcome {
3482 session_id,
3483 entity_id,
3484 snapshot,
3485 }) => {
3486 self.state
3487 .restore_from_welcome(session_id, entity_id, &snapshot);
3488 self.state.push_log(format!(
3489 "Connected — session {session_id}, entity {entity_id}"
3490 ));
3491 return Ok(());
3492 }
3493 Some(SessionEvent::Disconnected { .. }) => {
3494 anyhow::bail!("disconnected before welcome");
3495 }
3496 Some(_) => continue,
3497 None => anyhow::bail!("session closed before welcome"),
3498 }
3499 }
3500 }
3501
3502 pub fn drain_events(&mut self) {
3504 while let Some(event) = self.session.try_next_event() {
3505 if self.handle_event_sync(event).is_err() {
3506 break;
3507 }
3508 }
3509 }
3510
3511 pub async fn next_event(&mut self) -> Option<SessionEvent> {
3513 self.session.next_event().await
3514 }
3515
3516 pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
3517 self.handle_event_sync(event)
3518 }
3519
3520 fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
3521 match event {
3522 SessionEvent::Welcome {
3523 session_id,
3524 entity_id,
3525 snapshot,
3526 } => {
3527 let resumed = self.state.connected;
3528 self.state
3529 .restore_from_welcome(session_id, entity_id, &snapshot);
3530 if resumed {
3531 self.state.push_log(format!(
3532 "Session restored — session {session_id}, entity {entity_id}"
3533 ));
3534 }
3535 }
3536 SessionEvent::ContentUpdated { snapshot } => {
3537 self.state
3538 .apply_snapshot_fields(&snapshot, self.state.entity_id);
3539 self.state.push_log(format!(
3540 "World updated (content rev {})",
3541 snapshot.content_rev
3542 ));
3543 }
3544 SessionEvent::Tick(delta) => {
3545 self.state.apply_tick_fields(&delta, self.state.entity_id);
3546 self.state.ticks_received += 1;
3547 }
3548 SessionEvent::IntentAck {
3549 entity_id,
3550 seq,
3551 tick,
3552 } => {
3553 crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
3554 if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
3555 if *craft_seq == seq {
3556 let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
3557 if batches > 1 {
3558 self.state.push_log(format!("Crafting {label} ×{batches}…"));
3559 } else {
3560 self.state.push_log(format!("Crafting {label}…"));
3561 }
3562 }
3563 }
3564 if self
3565 .state
3566 .pending_worker_job_ack
3567 .as_ref()
3568 .is_some_and(|p| p.seq == seq)
3569 {
3570 let pending = self.state.pending_worker_job_ack.take().unwrap();
3571 if pending.idle {
3572 self.state.push_log(format!(
3573 "Route cleared for {} — worker idle",
3574 pending.worker_label
3575 ));
3576 } else {
3577 self.state.push_log(format!(
3578 "Route saved for {} — {} stop(s), job loop active",
3579 pending.worker_label, pending.stop_count
3580 ));
3581 }
3582 if self
3583 .state
3584 .worker_route_editor
3585 .as_ref()
3586 .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
3587 {
3588 self.close_worker_route_editor();
3589 }
3590 }
3591 }
3592 SessionEvent::Chat(msg) => {
3593 let label = match msg.channel {
3594 flatland_protocol::ChatChannel::Nearby => "nearby",
3595 flatland_protocol::ChatChannel::WhisperStone => "whisper",
3596 };
3597 self.state
3598 .push_log(format!("[{label}] {}: {}", msg.from_name, msg.text));
3599 }
3600 SessionEvent::HarvestResult(result) => {
3601 self.state.clear_harvest_state();
3602 crate::harvest_trace!(
3603 entity_id = self.state.entity_id,
3604 node_id = %result.node_id,
3605 template = %result.item_template,
3606 quantity = result.quantity,
3607 client_tick = self.state.tick,
3608 "client applied harvest result"
3609 );
3610 self.state.push_log(format!(
3611 "Harvested {} x{} (on the ground — press P to pick up)",
3612 result.item_template, result.quantity
3613 ));
3614 }
3615 SessionEvent::CraftResult(result) => {
3616 for stack in &result.consumed {
3617 if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
3618 *qty = qty.saturating_sub(stack.quantity);
3619 if *qty == 0 {
3620 self.state.inventory.remove(&stack.template_id);
3621 }
3622 }
3623 }
3624 for stack in &result.outputs {
3625 *self
3626 .state
3627 .inventory
3628 .entry(stack.template_id.clone())
3629 .or_insert(0) += stack.quantity;
3630 }
3631 if let Some(output) = result.outputs.first() {
3632 if result.batch_total > 1 {
3633 self.state.push_log(format!(
3634 "Crafted {} x{} ({}/{})",
3635 output.template_id,
3636 output.quantity,
3637 result.batch_index,
3638 result.batch_total
3639 ));
3640 } else {
3641 self.state.push_log(format!(
3642 "Crafted {} x{}",
3643 output.template_id, output.quantity
3644 ));
3645 }
3646 } else {
3647 self.state
3648 .push_log(format!("Craft finished: {}", result.blueprint_id));
3649 }
3650 }
3651 SessionEvent::Death(notice) => {
3652 self.state.clear_harvest_state();
3653 self.state.push_log(notice.message.clone());
3654 self.state.push_log(format!(
3655 "Respawned at ({:.1}, {:.1})",
3656 notice.respawn_x, notice.respawn_y
3657 ));
3658 }
3659 SessionEvent::Interaction(notice) => {
3660 if notice.message.starts_with("Harvest failed:") {
3661 self.state.clear_harvest_state();
3662 }
3663 if notice.message.starts_with("Can't do that:") {
3664 self.state.pending_craft_ack = None;
3665 if let Some(pending) = self.state.pending_worker_job_ack.take() {
3666 if let Some(w) = self
3667 .state
3668 .hired_workers
3669 .iter_mut()
3670 .find(|w| w.instance_id == pending.worker_instance_id)
3671 {
3672 w.route = pending.prev_route;
3673 w.mode = pending.prev_mode;
3674 w.step_label = pending.prev_step_label;
3675 w.last_error = pending.prev_last_error;
3676 }
3677 let reason = notice
3678 .message
3679 .strip_prefix("Can't do that:")
3680 .unwrap_or(¬ice.message)
3681 .trim();
3682 self.state.push_log(format!(
3683 "Route save failed for {}: {reason}",
3684 pending.worker_label
3685 ));
3686 }
3687 }
3688 if notice.message.starts_with("Cast failed:") {
3689 self.state.cast_progress = None;
3690 }
3691 if notice.message.contains("slain the") {
3692 self.state.combat_target = None;
3693 self.state.combat_target_label = None;
3694 }
3695 self.state.apply_interaction_notice(¬ice);
3696 self.state.push_log(notice.message.clone());
3697 }
3698 SessionEvent::ShopOpened(catalog) => {
3699 self.state.apply_shop_catalog(catalog);
3700 }
3701 SessionEvent::NpcTalkOpened(opened) => {
3702 self.state.show_npc_verb_menu = false;
3703 if self.state.npc_verb_target.is_none() {
3704 self.state.npc_verb_target = Some(opened.npc_id.clone());
3705 }
3706 let label = opened.npc_label.clone();
3707 let banner = if !opened.trade_allowed {
3708 Some("Trade is unavailable right now.".to_string())
3709 } else {
3710 None
3711 };
3712 self.state.show_npc_chat = true;
3713 self.state.npc_chat = Some(NpcChatState {
3714 npc_id: opened.npc_id,
3715 npc_label: opened.npc_label,
3716 lines: if opened.greeting.is_empty() {
3717 vec![]
3718 } else {
3719 vec![format!("{label}: {}", opened.greeting)]
3720 },
3721 input: String::new(),
3722 pending: opened.greeting.is_empty(),
3723 talk_depth: opened.talk_depth,
3724 trade_allowed: opened.trade_allowed,
3725 banner,
3726 });
3727 }
3728 SessionEvent::NpcTalkPending(_) => {
3729 if let Some(chat) = self.state.npc_chat.as_mut() {
3730 chat.pending = true;
3731 }
3732 }
3733 SessionEvent::NpcTalkReply(reply) => {
3734 if let Some(chat) = self.state.npc_chat.as_mut() {
3735 if chat.npc_id == reply.npc_id {
3736 chat.pending = false;
3737 if reply.trade_disabled {
3738 chat.trade_allowed = false;
3739 chat.banner = Some("Trade is unavailable right now.".to_string());
3740 }
3741 if reply.wind_down {
3742 chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
3743 if chat.banner.is_none() {
3744 chat.banner =
3745 Some("They're wrapping up — keep it brief.".to_string());
3746 }
3747 }
3748 chat.lines
3749 .push(format!("{}: {}", chat.npc_label, reply.line));
3750 }
3751 }
3752 }
3753 SessionEvent::NpcTalkClosed(closed) => {
3754 if self
3755 .state
3756 .npc_chat
3757 .as_ref()
3758 .is_some_and(|c| c.npc_id == closed.npc_id)
3759 {
3760 self.state.show_npc_chat = false;
3761 self.state.npc_chat = None;
3762 }
3763 }
3764 SessionEvent::NpcTalkError(err) => {
3765 self.state.push_log(format!("Talk failed: {}", err.reason));
3766 if let Some(chat) = self.state.npc_chat.as_mut() {
3767 chat.pending = false;
3768 }
3769 }
3770 SessionEvent::UseResult(result) => {
3771 if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
3774 *qty = qty.saturating_sub(1);
3775 if *qty == 0 {
3776 self.state.inventory.remove(&result.template_id);
3777 }
3778 }
3779 }
3780 SessionEvent::QuestOffer(offer) => {
3781 self.state.pending_quest_offer = Some(offer.clone());
3782 self.state.show_quest_offer = true;
3783 self.state
3784 .push_log(format!("Quest offered: {}", offer.title));
3785 }
3786 SessionEvent::QuestAccepted(notice) => {
3787 self.state.show_quest_offer = false;
3788 self.state.pending_quest_offer = None;
3789 self.state.push_log(notice.message);
3790 }
3791 SessionEvent::QuestWithdrawn(notice) => {
3792 self.state.show_quest_menu = false;
3793 self.state.quest_withdraw_confirm = false;
3794 self.state.push_log(notice.message);
3795 }
3796 SessionEvent::QuestStepCompleted(notice) => {
3797 self.state.push_log(notice.message);
3798 }
3799 SessionEvent::QuestCompleted(notice) => {
3800 self.state.push_log(notice.message);
3801 }
3802 SessionEvent::Disconnected { reason } => {
3803 self.state.clear_harvest_state();
3804 self.state.connected = false;
3805 self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
3806 if let Some(r) = &self.state.disconnect_reason {
3807 self.state.push_log(format!("Disconnected: {r}"));
3808 } else {
3809 self.state.push_log("Disconnected from server");
3810 }
3811 }
3812 }
3813 Ok(())
3814 }
3815
3816 pub fn is_connected(&self) -> bool {
3817 self.state.connected
3818 }
3819
3820 pub fn close_overlays(&mut self) {
3821 self.state.show_stats = false;
3822 self.state.show_craft_menu = false;
3823 self.state.show_shop_menu = false;
3824 self.state.shop_catalog = None;
3825 self.state.show_npc_verb_menu = false;
3826 self.state.npc_verb_target = None;
3827 self.state.show_npc_chat = false;
3828 self.state.npc_chat = None;
3829 self.state.show_inventory_menu = false;
3830 self.state.show_loadout_menu = false;
3831 self.state.show_rotation_editor = false;
3832 self.state.rotation_editor.reset();
3833 self.state.show_rename_prompt = false;
3834 self.state.show_worker_rename = false;
3835 self.state.rename_buffer.clear();
3836 self.state.show_move_picker = false;
3837 self.state.move_picker = None;
3838 self.state.show_destroy_picker = false;
3839 self.state.destroy_confirm_pending = false;
3840 self.state.destroy_picker = None;
3841 self.state.show_quest_offer = false;
3842 self.state.pending_quest_offer = None;
3843 self.state.show_quest_menu = false;
3844 self.state.quest_withdraw_confirm = false;
3845 self.state.show_workers_menu = false;
3846 self.close_worker_give_picker();
3847 self.close_worker_give_target_picker();
3848 self.close_worker_take_picker();
3849 self.close_worker_teach_picker();
3850 self.state.worker_route_editor = None;
3851 }
3852
3853 pub fn back_on_esc(&mut self) -> bool {
3855 if self.state.show_rename_prompt {
3856 self.cancel_rename_prompt();
3857 return true;
3858 }
3859 if self.state.show_worker_rename {
3860 self.cancel_worker_rename();
3861 return true;
3862 }
3863 if self.state.show_destroy_picker {
3864 if self.state.destroy_confirm_pending {
3865 self.cancel_destroy_confirm();
3866 } else {
3867 self.close_destroy_picker();
3868 }
3869 return true;
3870 }
3871 if self.state.show_move_picker {
3872 self.close_move_picker();
3873 return true;
3874 }
3875 if self.state.show_rotation_editor {
3876 match self.state.rotation_editor.mode {
3877 RotationEditorMode::List => {
3878 self.state.show_rotation_editor = false;
3879 self.state.rotation_editor.reset();
3880 }
3881 RotationEditorMode::EditLabel => {
3882 self.state.rotation_editor.label_buffer.clear();
3883 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
3884 }
3885 RotationEditorMode::PickAbility => {
3886 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
3887 }
3888 RotationEditorMode::EditSequence => {
3889 self.state.rotation_editor.draft = None;
3890 self.state.rotation_editor.mode = RotationEditorMode::List;
3891 }
3892 }
3893 return true;
3894 }
3895 if self.state.show_inventory_menu {
3896 self.close_inventory_menu();
3897 return true;
3898 }
3899 if self.state.show_craft_menu {
3900 self.close_craft_menu();
3901 return true;
3902 }
3903 if self.state.show_quest_offer {
3904 self.quest_offer_decline();
3905 return true;
3906 }
3907 if self.state.show_shop_menu {
3908 self.back_from_shop_menu();
3909 return true;
3910 }
3911 if self.state.show_npc_chat {
3912 return false;
3914 }
3915 if self.state.show_npc_verb_menu {
3916 self.state.show_npc_verb_menu = false;
3917 self.state.npc_verb_target = None;
3918 return true;
3919 }
3920 if self.state.show_quest_menu {
3921 if self.state.quest_withdraw_confirm {
3922 self.state.quest_withdraw_confirm = false;
3923 } else {
3924 self.state.show_quest_menu = false;
3925 }
3926 return true;
3927 }
3928 if self.state.worker_route_editor.is_some() {
3929 if self.re_at_root_sheet() {
3931 self.close_worker_route_editor();
3932 } else {
3933 self.re_sheet_back();
3934 }
3935 return true;
3936 }
3937 if self.state.show_worker_give_picker {
3938 self.close_worker_give_picker();
3939 return true;
3940 }
3941 if self.state.show_worker_give_target_picker {
3942 self.close_worker_give_target_picker();
3943 return true;
3944 }
3945 if self.state.show_worker_take_picker {
3946 self.close_worker_take_picker();
3947 return true;
3948 }
3949 if self.state.show_worker_teach_picker {
3950 self.close_worker_teach_picker();
3951 return true;
3952 }
3953 if self.state.show_workers_menu {
3954 self.state.show_workers_menu = false;
3955 return true;
3956 }
3957 if self.state.show_loadout_menu {
3958 self.state.show_loadout_menu = false;
3959 return true;
3960 }
3961 if self.state.show_stats {
3962 self.state.show_stats = false;
3963 return true;
3964 }
3965 if self.state.show_equip_menu {
3966 self.state.show_equip_menu = false;
3967 return true;
3968 }
3969 false
3970 }
3971
3972 pub fn toggle_stats(&mut self) {
3973 self.state.show_stats = !self.state.show_stats;
3974 if self.state.show_stats {
3975 self.state.character_sheet_tab = CharacterSheetTab::Character;
3976 self.state.show_craft_menu = false;
3977 self.state.show_shop_menu = false;
3978 self.state.shop_catalog = None;
3979 self.state.show_inventory_menu = false;
3980 self.state.show_equip_menu = false;
3981 }
3982 }
3983
3984 pub fn toggle_equip_menu(&mut self) {
3985 self.state.show_equip_menu = !self.state.show_equip_menu;
3986 if self.state.show_equip_menu {
3987 self.state.show_stats = false;
3988 self.state.show_craft_menu = false;
3989 self.state.show_shop_menu = false;
3990 self.state.shop_catalog = None;
3991 self.state.show_inventory_menu = false;
3992 self.state.show_loadout_menu = false;
3993 }
3994 }
3995
3996 pub fn cycle_character_sheet_tab(&mut self) {
3997 if self.state.show_stats {
3998 self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
3999 }
4000 }
4001
4002 pub fn set_ledger_period_digit(&mut self, c: char) {
4003 if self.state.show_stats {
4004 if let Some(p) = LedgerPeriod::from_digit(c) {
4005 self.state.ledger_period = p;
4006 self.state.character_sheet_tab = CharacterSheetTab::Ledger;
4007 }
4008 }
4009 }
4010
4011 pub fn cycle_ledger_period(&mut self) {
4012 if self.state.show_stats
4013 && self.state.character_sheet_tab == CharacterSheetTab::Ledger
4014 {
4015 self.state.ledger_period = self.state.ledger_period.cycle();
4016 }
4017 }
4018
4019 pub fn open_inventory_menu(&mut self) {
4020 self.state.show_inventory_menu = true;
4021 self.state.show_craft_menu = false;
4022 self.state.show_shop_menu = false;
4023 self.state.shop_catalog = None;
4024 self.state.show_stats = false;
4025 self.state.show_move_picker = false;
4026 self.state.move_picker = None;
4027 self.state.show_destroy_picker = false;
4028 self.state.destroy_confirm_pending = false;
4029 self.state.destroy_picker = None;
4030 self.state.show_rename_prompt = false;
4031 self.state.rename_buffer.clear();
4032 self.state.clamp_inventory_indices();
4033 }
4034
4035 pub fn close_inventory_menu(&mut self) {
4036 self.state.show_inventory_menu = false;
4037 self.state.show_move_picker = false;
4038 self.state.move_picker = None;
4039 self.state.show_destroy_picker = false;
4040 self.state.destroy_confirm_pending = false;
4041 self.state.destroy_picker = None;
4042 self.state.show_rename_prompt = false;
4043 self.state.rename_buffer.clear();
4044 }
4045
4046 pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
4047 let Some(row) = self.state.inventory_selected_row() else {
4048 anyhow::bail!("inventory empty");
4049 };
4050 if !self.state.row_is_renameable_container(&row) {
4051 anyhow::bail!("only storage containers can be renamed");
4052 }
4053 let current = row
4054 .stack
4055 .display_name
4056 .clone()
4057 .unwrap_or_else(|| row.stack.template_id.clone());
4058 self.state.rename_buffer = current;
4059 self.state.show_rename_prompt = true;
4060 self.state.show_worker_rename = false;
4061 self.state.show_move_picker = false;
4062 self.state.show_destroy_picker = false;
4063 self.state.destroy_confirm_pending = false;
4064 Ok(())
4065 }
4066
4067 pub fn cancel_rename_prompt(&mut self) {
4068 self.state.show_rename_prompt = false;
4069 self.state.rename_buffer.clear();
4070 }
4071
4072 pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
4073 let name = self.state.rename_buffer.trim().to_string();
4074 if name.is_empty() {
4075 anyhow::bail!("name cannot be empty");
4076 }
4077 let Some(row) = self.state.inventory_selected_row() else {
4078 anyhow::bail!("inventory empty");
4079 };
4080 let Some(instance_id) = row.stack.item_instance_id else {
4081 anyhow::bail!("item has no instance id");
4082 };
4083 self.seq += 1;
4084 self.session
4085 .submit_intent(Intent::RenameContainer {
4086 entity_id: self.state.entity_id,
4087 item_instance_id: instance_id,
4088 location: row.from.clone(),
4089 name,
4090 seq: self.seq,
4091 })
4092 .await?;
4093 self.state.intents_sent += 1;
4094 self.state.show_rename_prompt = false;
4095 self.state.rename_buffer.clear();
4096 Ok(())
4097 }
4098
4099 pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
4100 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
4101 anyhow::bail!("no worker selected");
4102 };
4103 self.state.rename_buffer = worker.label.clone();
4104 self.state.show_worker_rename = true;
4105 self.state.show_rename_prompt = false;
4106 Ok(())
4107 }
4108
4109 pub fn cancel_worker_rename(&mut self) {
4110 self.state.show_worker_rename = false;
4111 self.state.rename_buffer.clear();
4112 }
4113
4114 pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
4115 let name = self.state.rename_buffer.trim().to_string();
4116 if name.is_empty() {
4117 anyhow::bail!("name cannot be empty");
4118 }
4119 if name.chars().count() > 32 {
4120 anyhow::bail!("name must be 1–32 characters");
4121 }
4122 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
4123 anyhow::bail!("no worker selected");
4124 };
4125 let worker_instance_id = worker.instance_id.clone();
4126 self.seq += 1;
4127 self.session
4128 .submit_intent(Intent::RenameHiredWorker {
4129 entity_id: self.state.entity_id,
4130 worker_instance_id: worker_instance_id.clone(),
4131 name: name.clone(),
4132 seq: self.seq,
4133 })
4134 .await?;
4135 self.state.intents_sent += 1;
4136 if let Some(w) = self
4137 .state
4138 .hired_workers
4139 .iter_mut()
4140 .find(|w| w.instance_id == worker_instance_id)
4141 {
4142 w.label = name.clone();
4143 }
4144 if let Some(ed) = self.state.worker_route_editor.as_mut() {
4145 if ed.worker_instance_id == worker_instance_id {
4146 ed.worker_label = name.clone();
4147 }
4148 }
4149 self.state.show_worker_rename = false;
4150 self.state.rename_buffer.clear();
4151 self.state.push_log(format!("Renamed worker to \"{name}\""));
4152 Ok(())
4153 }
4154
4155 pub fn toggle_inventory_menu(&mut self) {
4156 if self.state.show_inventory_menu {
4157 self.close_inventory_menu();
4158 } else {
4159 self.open_inventory_menu();
4160 }
4161 }
4162
4163 pub fn inventory_menu_move(&mut self, delta: i32) {
4165 if self.state.show_move_picker {
4166 let n = self
4167 .state
4168 .move_picker
4169 .as_ref()
4170 .map(|p| p.options.len())
4171 .unwrap_or(0);
4172 if n == 0 {
4173 return;
4174 }
4175 let idx = self.state.move_picker_index as i32;
4176 self.state.move_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
4177 self.state.clamp_move_picker_quantity();
4178 return;
4179 }
4180 let n = self.state.inventory_selectable_rows().len();
4181 if n == 0 {
4182 return;
4183 }
4184 let idx = self.state.inventory_menu_index as i32;
4185 self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
4186 }
4187
4188 pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
4193 if self.state.show_destroy_picker {
4194 if self.state.destroy_confirm_pending {
4195 return self.confirm_destroy_item().await;
4196 }
4197 return self.request_destroy_confirm();
4198 }
4199 if self.state.show_move_picker {
4200 return self.confirm_move_picker().await;
4201 }
4202 let Some(row) = self.state.inventory_selected_row() else {
4203 anyhow::bail!("inventory empty");
4204 };
4205 if row.is_equip_shell {
4206 let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
4207 anyhow::bail!("not a worn item");
4208 };
4209 return self.equip_worn(slot, None).await;
4210 }
4211 if row.is_chest_shell {
4212 return self.open_chest_pickup_picker();
4213 }
4214 let template_id = row.stack.template_id.clone();
4215 let instance_id = row.stack.item_instance_id;
4216 let category = self.state.inventory_item_category(&template_id);
4217 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
4218
4219 if category == Some("weapon") {
4220 return self.equip_mainhand(Some(template_id)).await;
4221 }
4222 if category == Some("lodging") && on_person {
4223 if let Some(inst) = instance_id {
4224 return self.place_container(inst).await;
4225 }
4226 }
4227 if (category == Some("container") || category == Some("armor")) && on_person {
4228 if let Some(inst) = instance_id {
4229 let world_placeable = row.stack.world_placeable == Some(true)
4230 || template_id.contains("chest");
4231 if world_placeable {
4232 return self.place_container(inst).await;
4233 }
4234 if let Some(slot) = guess_body_slot(&template_id) {
4238 return self.equip_worn(slot, Some(inst)).await;
4239 }
4240 }
4241 }
4242 self.open_move_picker()
4246 }
4247
4248 pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
4250 let Some(row) = self.state.inventory_selected_row() else {
4251 anyhow::bail!("inventory empty");
4252 };
4253 if row.from != flatland_protocol::InventoryLocation::Root {
4254 anyhow::bail!("select a consumable on your person");
4255 }
4256 let category = self
4257 .state
4258 .inventory_item_category(&row.stack.template_id);
4259 if category != Some("consumable") {
4260 anyhow::bail!("selected item is not consumable");
4261 }
4262 self.use_item(&row.stack.template_id).await
4263 }
4264
4265 pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
4269 let Some(row) = self.state.inventory_selected_row() else {
4270 anyhow::bail!("inventory empty");
4271 };
4272 if row.is_equip_shell {
4273 anyhow::bail!("this is a worn bag — press Enter to unequip it");
4274 }
4275 if row.is_chest_shell {
4276 return self.open_chest_pickup_picker();
4277 }
4278 let Some(instance_id) = row.stack.item_instance_id else {
4279 anyhow::bail!("item has no instance id");
4280 };
4281 let mut options = self.state.move_destinations_for(
4282 &row.from,
4283 row.from_parent_instance_id,
4284 row.stack.item_instance_id,
4285 &row.stack.template_id,
4286 );
4287 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
4288 let category = self.state.inventory_item_category(&row.stack.template_id);
4289 if on_person && category == Some("consumable") {
4290 options.insert(
4291 0,
4292 MoveOption {
4293 label: "Use (eat / drink)".into(),
4294 kind: MoveOptionKind::Use,
4295 },
4296 );
4297 }
4298 let item_label = row
4299 .stack
4300 .display_name
4301 .clone()
4302 .unwrap_or_else(|| row.stack.template_id.clone());
4303 let initial_qty = if row.stack.quantity > 1 { 1 } else { row.stack.quantity };
4306 self.state.move_picker = Some(MovePicker {
4307 item_instance_id: instance_id,
4308 from: row.from,
4309 item_label,
4310 template_id: row.stack.template_id.clone(),
4311 stack_quantity: row.stack.quantity,
4312 quantity: initial_qty.max(1),
4313 options,
4314 });
4315 self.state.move_picker_index = 0;
4316 self.state.show_move_picker = true;
4317 self.state.show_destroy_picker = false;
4318 self.state.destroy_confirm_pending = false;
4319 self.state.destroy_picker = None;
4320 self.state.clamp_move_picker_quantity();
4321 Ok(())
4322 }
4323
4324 pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
4326 let Some(row) = self.state.inventory_selected_row() else {
4327 anyhow::bail!("inventory empty");
4328 };
4329 if !row.is_chest_shell {
4330 anyhow::bail!("not a placed chest");
4331 }
4332 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
4333 anyhow::bail!("not a placed chest");
4334 };
4335 let Some(instance_id) = row.stack.item_instance_id else {
4336 anyhow::bail!("chest has no instance id");
4337 };
4338 let chest = self
4339 .state
4340 .placed_containers
4341 .iter()
4342 .find(|c| c.id == *container_id)
4343 .cloned()
4344 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
4345 let (px, py) = self.state.player_position();
4346 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
4347 anyhow::bail!("too far from {}", chest.display_name);
4348 }
4349 if chest.locked && !chest.accessible {
4350 anyhow::bail!(
4351 "need the matching key for {} before picking it up",
4352 chest.display_name
4353 );
4354 }
4355 let options = self.state.chest_pickup_destinations(container_id);
4356 let item_label = row
4357 .stack
4358 .display_name
4359 .clone()
4360 .unwrap_or_else(|| row.stack.template_id.clone());
4361 self.state.move_picker = Some(MovePicker {
4362 item_instance_id: instance_id,
4363 from: row.from.clone(),
4364 item_label,
4365 template_id: row.stack.template_id.clone(),
4366 stack_quantity: 1,
4367 quantity: 1,
4368 options,
4369 });
4370 self.state.move_picker_index = 0;
4371 self.state.show_move_picker = true;
4372 self.state.show_destroy_picker = false;
4373 self.state.destroy_confirm_pending = false;
4374 self.state.destroy_picker = None;
4375 Ok(())
4376 }
4377
4378 pub fn close_move_picker(&mut self) {
4379 self.state.show_move_picker = false;
4380 self.state.move_picker = None;
4381 }
4382
4383 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
4384 self.state.move_picker_adjust_quantity(delta);
4385 }
4386
4387 pub fn move_picker_set_quantity_max(&mut self) {
4388 self.state.move_picker_set_quantity_max();
4389 }
4390
4391 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
4392 self.state.destroy_picker_adjust_quantity(delta);
4393 }
4394
4395 pub fn destroy_picker_set_quantity_max(&mut self) {
4396 self.state.destroy_picker_set_quantity_max();
4397 }
4398
4399 async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
4400 let Some(picker) = self.state.move_picker.clone() else {
4401 self.close_move_picker();
4402 return Ok(());
4403 };
4404 let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
4405 self.close_move_picker();
4406 return Ok(());
4407 };
4408 match option.kind {
4409 MoveOptionKind::Cancel => {
4410 self.close_move_picker();
4411 }
4412 MoveOptionKind::Use => {
4413 self.close_move_picker();
4414 self.use_item(&picker.template_id).await?;
4415 }
4416 MoveOptionKind::Drop => {
4417 self.close_move_picker();
4418 if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
4419 if self.state.key_drop_blocked(&stack) {
4420 anyhow::bail!("cannot drop the key while its chest is locked");
4421 }
4422 }
4423 self.drop_item(picker.item_instance_id, picker.from).await?;
4424 self.state
4425 .push_log(format!("Dropped {}", picker.item_label));
4426 }
4427 MoveOptionKind::PickupPlaced {
4428 container_id,
4429 nest_location,
4430 nest_parent_instance_id,
4431 } => {
4432 self.close_move_picker();
4433 self.pickup_container(container_id.clone()).await?;
4434 let nest_into_bag = nest_parent_instance_id.is_some()
4435 || !matches!(
4436 nest_location,
4437 flatland_protocol::InventoryLocation::Root
4438 );
4439 if nest_into_bag {
4440 self.move_item(
4441 picker.item_instance_id,
4442 flatland_protocol::InventoryLocation::Root,
4443 nest_location,
4444 nest_parent_instance_id,
4445 None,
4446 )
4447 .await?;
4448 self.state
4449 .push_log(format!("Picked up {} into bag", picker.item_label));
4450 } else {
4451 self.state
4452 .push_log(format!("Picked up {}", picker.item_label));
4453 }
4454 }
4455 MoveOptionKind::Move {
4456 location,
4457 parent_instance_id,
4458 } => {
4459 self.close_move_picker();
4460 let qty = if picker.quantity >= picker.stack_quantity {
4461 None
4462 } else {
4463 Some(picker.quantity)
4464 };
4465 self.move_item(
4466 picker.item_instance_id,
4467 picker.from,
4468 location,
4469 parent_instance_id,
4470 qty,
4471 )
4472 .await?;
4473 let moved = qty.unwrap_or(picker.stack_quantity);
4474 if moved >= picker.stack_quantity {
4475 self.state.push_log(format!("Moved {}", picker.item_label));
4476 } else {
4477 self.state.push_log(format!(
4478 "Moved {} ×{} of {}",
4479 picker.item_label, moved, picker.stack_quantity
4480 ));
4481 }
4482 }
4483 }
4484 Ok(())
4485 }
4486
4487 pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
4489 let Some(row) = self.state.inventory_selected_row() else {
4490 anyhow::bail!("inventory empty");
4491 };
4492 if row.is_equip_shell {
4493 anyhow::bail!("unequip the bag first (Enter), then drop from your person");
4494 }
4495 if row.is_chest_shell {
4496 anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
4497 }
4498 let Some(inst) = row.stack.item_instance_id else {
4499 anyhow::bail!("item has no instance id");
4500 };
4501 if self.state.key_drop_blocked(&row.stack) {
4502 anyhow::bail!("cannot drop the key while its chest is locked");
4503 }
4504 let label = row
4505 .stack
4506 .display_name
4507 .clone()
4508 .unwrap_or_else(|| row.stack.template_id.clone());
4509 self.drop_item(inst, row.from).await?;
4510 self.state.push_log(format!("Dropped {label}"));
4511 Ok(())
4512 }
4513
4514 pub async fn drop_item(
4515 &mut self,
4516 item_instance_id: uuid::Uuid,
4517 from: flatland_protocol::InventoryLocation,
4518 ) -> anyhow::Result<()> {
4519 self.seq += 1;
4520 self.session
4521 .submit_intent(Intent::DropItem {
4522 entity_id: self.state.entity_id,
4523 item_instance_id,
4524 from,
4525 seq: self.seq,
4526 })
4527 .await?;
4528 self.state.intents_sent += 1;
4529 Ok(())
4530 }
4531
4532 pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
4534 let Some(row) = self.state.inventory_selected_row() else {
4535 anyhow::bail!("inventory empty");
4536 };
4537 if row.is_equip_shell {
4538 anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
4539 }
4540 if row.is_chest_shell {
4541 anyhow::bail!("can't destroy a placed chest from the inventory list");
4542 }
4543 let Some(instance_id) = row.stack.item_instance_id else {
4544 anyhow::bail!("item has no instance id");
4545 };
4546 if self.state.key_drop_blocked(&row.stack) {
4547 anyhow::bail!("cannot destroy the key while its chest is locked");
4548 }
4549 let item_label = row
4550 .stack
4551 .display_name
4552 .clone()
4553 .unwrap_or_else(|| row.stack.template_id.clone());
4554 self.state.destroy_picker = Some(DestroyPicker {
4555 item_instance_id: instance_id,
4556 from: row.from,
4557 item_label,
4558 stack_quantity: row.stack.quantity,
4559 quantity: row.stack.quantity,
4560 });
4561 self.state.destroy_confirm_pending = false;
4562 self.state.show_destroy_picker = true;
4563 self.state.show_move_picker = false;
4564 self.state.move_picker = None;
4565 Ok(())
4566 }
4567
4568 pub fn close_destroy_picker(&mut self) {
4569 self.state.show_destroy_picker = false;
4570 self.state.destroy_confirm_pending = false;
4571 self.state.destroy_picker = None;
4572 }
4573
4574 pub fn cancel_destroy_confirm(&mut self) {
4575 self.state.destroy_confirm_pending = false;
4576 }
4577
4578 pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
4579 if self.state.destroy_picker.is_none() {
4580 self.close_destroy_picker();
4581 return Ok(());
4582 }
4583 self.state.destroy_confirm_pending = true;
4584 Ok(())
4585 }
4586
4587 pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
4588 let Some(picker) = self.state.destroy_picker.clone() else {
4589 self.close_destroy_picker();
4590 return Ok(());
4591 };
4592 let qty = if picker.quantity >= picker.stack_quantity {
4593 None
4594 } else {
4595 Some(picker.quantity)
4596 };
4597 self.destroy_item(picker.item_instance_id, picker.from, qty)
4598 .await?;
4599 let destroyed = qty.unwrap_or(picker.stack_quantity);
4600 if destroyed >= picker.stack_quantity {
4601 self.state
4602 .push_log(format!("Destroyed {}", picker.item_label));
4603 } else {
4604 self.state.push_log(format!(
4605 "Destroyed {} ×{} of {}",
4606 picker.item_label, destroyed, picker.stack_quantity
4607 ));
4608 }
4609 self.close_destroy_picker();
4610 Ok(())
4611 }
4612
4613 pub async fn destroy_item(
4614 &mut self,
4615 item_instance_id: uuid::Uuid,
4616 from: flatland_protocol::InventoryLocation,
4617 quantity: Option<u32>,
4618 ) -> anyhow::Result<()> {
4619 self.seq += 1;
4620 self.session
4621 .submit_intent(Intent::DestroyItem {
4622 entity_id: self.state.entity_id,
4623 item_instance_id,
4624 from,
4625 quantity,
4626 seq: self.seq,
4627 })
4628 .await?;
4629 self.state.intents_sent += 1;
4630 Ok(())
4631 }
4632
4633 pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
4635 if let Some(row) = self.state.inventory_selected_row() {
4636 if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
4637 return self.toggle_placed_chest_lock(container_id).await;
4638 }
4639 }
4640 self.toggle_nearby_chest_lock().await
4641 }
4642
4643 pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
4644 let chest = self
4645 .state
4646 .placed_containers
4647 .iter()
4648 .find(|c| c.id == container_id)
4649 .cloned()
4650 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
4651 let (px, py) = self.state.player_position();
4652 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
4653 anyhow::bail!("too far from {}", chest.display_name);
4654 }
4655 if !chest.accessible && chest.locked {
4656 anyhow::bail!(
4657 "need the matching key for {} (each crafted chest has its own key)",
4658 chest.display_name
4659 );
4660 }
4661 let lock = !chest.locked;
4662 self.set_container_locked(
4663 flatland_protocol::InventoryLocation::Placed {
4664 container_id: chest.id.clone(),
4665 },
4666 lock,
4667 )
4668 .await?;
4669 self.state.push_log(if lock {
4670 format!("Locked {}", chest.display_name)
4671 } else {
4672 format!("Unlocked {}", chest.display_name)
4673 });
4674 Ok(())
4675 }
4676
4677 pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
4679 let chest = self
4680 .state
4681 .nearest_placed_container(CONTAINER_RANGE_M)
4682 .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
4683 self.toggle_placed_chest_lock(&chest.id).await
4684 }
4685
4686 pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
4687 self.equip_mainhand(None).await
4688 }
4689
4690 pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
4691 if !self.state.is_alive() {
4692 anyhow::bail!("you are dead");
4693 }
4694 self.seq += 1;
4695 self.session
4696 .submit_intent(Intent::EquipOffhand {
4697 entity_id: self.state.entity_id,
4698 template_id,
4699 seq: self.seq,
4700 })
4701 .await?;
4702 self.state.intents_sent += 1;
4703 Ok(())
4704 }
4705
4706 pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
4707 self.equip_offhand(None).await
4708 }
4709
4710 pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
4711 let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
4712 for slot in slots {
4713 self.equip_worn(slot, None).await?;
4714 }
4715 Ok(())
4716 }
4717
4718 pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
4719 let (px, py) = self.state.player_position();
4720 let nearest = self
4721 .state
4722 .placed_containers
4723 .iter()
4724 .min_by(|a, b| {
4725 let da = (a.x - px).hypot(a.y - py);
4726 let db = (b.x - px).hypot(b.y - py);
4727 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
4728 })
4729 .cloned();
4730 let Some(chest) = nearest else {
4731 anyhow::bail!("no chest nearby");
4732 };
4733 if (chest.x - px).hypot(chest.y - py) > 2.0 {
4734 anyhow::bail!("too far from chest");
4735 }
4736 self.pickup_container(chest.id).await
4737 }
4738
4739 pub async fn equip_worn(
4740 &mut self,
4741 slot: BodySlot,
4742 instance_id: Option<uuid::Uuid>,
4743 ) -> anyhow::Result<()> {
4744 self.seq += 1;
4745 self.session
4746 .submit_intent(Intent::EquipWorn {
4747 entity_id: self.state.entity_id,
4748 slot,
4749 instance_id,
4750 seq: self.seq,
4751 })
4752 .await?;
4753 self.state.intents_sent += 1;
4754 Ok(())
4755 }
4756
4757 pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
4758 self.seq += 1;
4759 self.session
4760 .submit_intent(Intent::PlaceContainer {
4761 entity_id: self.state.entity_id,
4762 item_instance_id,
4763 seq: self.seq,
4764 })
4765 .await?;
4766 self.state.intents_sent += 1;
4767 Ok(())
4768 }
4769
4770 pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
4771 self.seq += 1;
4772 self.session
4773 .submit_intent(Intent::PickupContainer {
4774 entity_id: self.state.entity_id,
4775 container_id,
4776 seq: self.seq,
4777 })
4778 .await?;
4779 self.state.intents_sent += 1;
4780 Ok(())
4781 }
4782
4783 pub async fn move_item(
4784 &mut self,
4785 item_instance_id: uuid::Uuid,
4786 from: flatland_protocol::InventoryLocation,
4787 to: flatland_protocol::InventoryLocation,
4788 to_parent_instance_id: Option<uuid::Uuid>,
4789 quantity: Option<u32>,
4790 ) -> anyhow::Result<()> {
4791 self.seq += 1;
4792 self.session
4793 .submit_intent(Intent::MoveItem {
4794 entity_id: self.state.entity_id,
4795 item_instance_id,
4796 from,
4797 to,
4798 to_parent_instance_id,
4799 quantity,
4800 seq: self.seq,
4801 })
4802 .await?;
4803 self.state.intents_sent += 1;
4804 Ok(())
4805 }
4806
4807 pub async fn set_container_locked(
4808 &mut self,
4809 location: flatland_protocol::InventoryLocation,
4810 locked: bool,
4811 ) -> anyhow::Result<()> {
4812 self.seq += 1;
4813 self.session
4814 .submit_intent(Intent::SetContainerLocked {
4815 entity_id: self.state.entity_id,
4816 location,
4817 locked,
4818 seq: self.seq,
4819 })
4820 .await?;
4821 self.state.intents_sent += 1;
4822 Ok(())
4823 }
4824
4825 pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
4826 if !self.state.is_alive() {
4827 anyhow::bail!("you are dead");
4828 }
4829 self.seq += 1;
4830 self.session
4831 .submit_intent(Intent::Use {
4832 entity_id: self.state.entity_id,
4833 template_id: template_id.to_string(),
4834 seq: self.seq,
4835 })
4836 .await?;
4837 self.state.intents_sent += 1;
4838 Ok(())
4839 }
4840
4841 pub fn open_craft_menu(&mut self) {
4842 self.state.show_craft_menu = true;
4843 self.state.show_shop_menu = false;
4844 self.state.shop_catalog = None;
4845 self.state.show_stats = false;
4846 self.state.show_inventory_menu = false;
4847 if self.state.blueprints.is_empty() {
4848 self.state.craft_menu_index = 0;
4849 self.state.craft_batch_quantity = 1;
4850 return;
4851 }
4852 self.state.craft_menu_index = self
4853 .state
4854 .craft_menu_index
4855 .min(self.state.blueprints.len() - 1);
4856 if let Some(idx) = self
4857 .state
4858 .blueprints
4859 .iter()
4860 .position(|bp| self.state.can_craft_blueprint(bp))
4861 {
4862 self.state.craft_menu_index = idx;
4863 }
4864 self.state.clamp_craft_batch_quantity();
4865 }
4866
4867 pub fn close_craft_menu(&mut self) {
4868 self.state.show_craft_menu = false;
4869 }
4870
4871 pub fn toggle_keychain_menu(&mut self) {
4872 if self.state.show_keychain_menu {
4873 self.close_keychain_menu();
4874 } else {
4875 self.state.show_keychain_menu = true;
4876 self.state.show_craft_menu = false;
4877 self.state.show_shop_menu = false;
4878 self.state.show_inventory_menu = false;
4879 let n = self.state.keychain_entries().len();
4880 if n == 0 {
4881 self.state.keychain_menu_index = 0;
4882 } else {
4883 self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
4884 }
4885 }
4886 }
4887
4888 pub fn close_keychain_menu(&mut self) {
4889 self.state.show_keychain_menu = false;
4890 }
4891
4892 pub fn keychain_menu_move(&mut self, delta: i32) {
4893 let n = self.state.keychain_entries().len();
4894 if n == 0 {
4895 self.state.keychain_menu_index = 0;
4896 return;
4897 }
4898 let idx = self.state.keychain_menu_index as i32 + delta;
4899 self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
4900 }
4901
4902 pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
4903 if !self.state.is_alive() {
4904 anyhow::bail!("you are dead");
4905 }
4906 let entries = self.state.keychain_entries();
4907 let Some(entry) = entries.get(self.state.keychain_menu_index) else {
4908 anyhow::bail!("nothing selected");
4909 };
4910 let Some(instance_id) = entry.stack.item_instance_id else {
4911 anyhow::bail!("key has no instance id");
4912 };
4913 if entry.stowed {
4914 self.move_item(
4915 instance_id,
4916 flatland_protocol::InventoryLocation::Keychain,
4917 flatland_protocol::InventoryLocation::Root,
4918 None,
4919 Some(1),
4920 )
4921 .await
4922 } else {
4923 self.move_item(
4924 instance_id,
4925 flatland_protocol::InventoryLocation::Root,
4926 flatland_protocol::InventoryLocation::Keychain,
4927 None,
4928 Some(1),
4929 )
4930 .await
4931 }
4932 }
4933
4934 pub fn close_shop_menu(&mut self) {
4935 self.state.show_shop_menu = false;
4936 self.state.shop_catalog = None;
4937 self.state.clear_shop_trade_log();
4938 }
4939
4940 pub fn back_from_shop_menu(&mut self) {
4942 let return_to_verbs = self.state.npc_verb_target.is_some();
4943 self.close_shop_menu();
4944 if return_to_verbs {
4945 self.state.show_npc_verb_menu = true;
4946 }
4947 }
4948
4949 pub fn shop_tab_toggle(&mut self) {
4950 self.state.shop_tab = match self.state.shop_tab {
4951 ShopTab::Buy => ShopTab::Sell,
4952 ShopTab::Sell => ShopTab::Buy,
4953 };
4954 self.state.shop_menu_index = 0;
4955 if self.state.shop_tab == ShopTab::Sell {
4956 self.state.shop_quantity_set_max();
4957 }
4958 self.state.clamp_shop_selection();
4959 }
4960
4961 pub fn shop_menu_move(&mut self, delta: i32) {
4962 self.state.shop_menu_move(delta);
4963 }
4964
4965 pub fn shop_quantity_adjust(&mut self, delta: i32) {
4966 self.state.shop_quantity_adjust(delta);
4967 }
4968
4969 pub fn shop_quantity_set_max(&mut self) {
4970 self.state.shop_quantity_set_max();
4971 }
4972
4973 pub fn toggle_quest_menu(&mut self) {
4974 self.state.show_quest_menu = !self.state.show_quest_menu;
4975 if self.state.show_quest_menu {
4976 self.state.quest_menu_index = 0;
4977 self.state.quest_withdraw_confirm = false;
4978 self.state.show_workers_menu = false;
4979 }
4980 }
4981
4982 pub fn toggle_workers_menu(&mut self) {
4983 self.state.show_workers_menu = !self.state.show_workers_menu;
4984 if self.state.show_workers_menu {
4985 self.state.workers_menu_index = 0;
4986 self.state.show_quest_menu = false;
4987 self.close_worker_give_picker();
4988 self.close_worker_give_target_picker();
4989 self.close_worker_take_picker();
4990 self.close_worker_teach_picker();
4991 self.cancel_worker_rename();
4992 } else {
4993 self.close_worker_give_picker();
4994 self.close_worker_give_target_picker();
4995 self.close_worker_take_picker();
4996 self.close_worker_teach_picker();
4997 self.cancel_worker_rename();
4998 }
4999 }
5000
5001 pub fn workers_menu_move(&mut self, delta: i32) {
5002 let n = self.state.hired_workers.len();
5003 if n == 0 {
5004 return;
5005 }
5006 let idx = self.state.workers_menu_index as i32;
5007 self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
5008 }
5009
5010 pub fn toggle_workers_menu_compact(&mut self) {
5011 self.state.workers_menu_compact = !self.state.workers_menu_compact;
5012 }
5013
5014 pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
5015 let Some(worker) = self
5016 .state
5017 .hired_workers
5018 .get(self.state.workers_menu_index)
5019 .cloned()
5020 else {
5021 anyhow::bail!("no worker selected");
5022 };
5023 self.seq += 1;
5024 self.session
5025 .submit_intent(Intent::DismissWorker {
5026 entity_id: self.state.entity_id,
5027 worker_instance_id: worker.instance_id.clone(),
5028 seq: self.seq,
5029 })
5030 .await?;
5031 self.state.intents_sent += 1;
5032 self.state
5033 .hired_workers
5034 .retain(|w| w.instance_id != worker.instance_id);
5035 if self.state.workers_menu_index >= self.state.hired_workers.len() {
5036 self.state.workers_menu_index = self
5037 .state
5038 .hired_workers
5039 .len()
5040 .saturating_sub(1);
5041 }
5042 self.state.push_log(format!("Dismissed {}", worker.label));
5043 Ok(())
5044 }
5045
5046 pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
5047 let Some(worker) = self
5048 .state
5049 .hired_workers
5050 .get(self.state.workers_menu_index)
5051 .cloned()
5052 else {
5053 anyhow::bail!("no worker selected");
5054 };
5055 let mode = match worker.mode {
5056 flatland_protocol::WorkerModeView::Companion => "job_loop",
5057 flatland_protocol::WorkerModeView::JobLoop => "idle",
5058 flatland_protocol::WorkerModeView::Idle => "companion",
5059 };
5060 self.seq += 1;
5061 self.session
5062 .submit_intent(Intent::SetWorkerMode {
5063 entity_id: self.state.entity_id,
5064 worker_instance_id: worker.instance_id,
5065 mode: mode.into(),
5066 seq: self.seq,
5067 })
5068 .await?;
5069 self.state.intents_sent += 1;
5070 Ok(())
5071 }
5072
5073 pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
5074 if self.state.hired_workers.is_empty() {
5075 return self.hire_worker_laborer().await;
5076 }
5077 self.workers_toggle_mode_selected().await
5078 }
5079
5080 pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
5083 let row = self
5084 .state
5085 .inventory_selected_row()
5086 .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
5087 .clone();
5088 if row.from != flatland_protocol::InventoryLocation::Root {
5089 anyhow::bail!("select a carried item to give");
5090 }
5091 let Some(instance_id) = row.stack.item_instance_id else {
5092 anyhow::bail!("that stack can't be given");
5093 };
5094 let options = self.nearby_worker_give_targets();
5095 if options.is_empty() {
5096 anyhow::bail!(
5097 "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
5098 );
5099 }
5100 let item_label = row
5101 .stack
5102 .display_name
5103 .as_deref()
5104 .unwrap_or(&row.stack.template_id)
5105 .to_string();
5106 self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
5107 item_instance_id: instance_id,
5108 item_label,
5109 quantity: None,
5110 options,
5111 });
5112 self.state.worker_give_target_picker_index = 0;
5113 self.state.show_worker_give_target_picker = true;
5114 self.state.show_inventory_menu = false;
5116 Ok(())
5117 }
5118
5119 pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
5121 let (px, py, _) = self.state.player_position_with_z();
5122 let mut options: Vec<WorkerGiveTargetOption> = self
5123 .state
5124 .hired_workers
5125 .iter()
5126 .filter_map(|w| {
5127 let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
5128 if dist > WORKER_GIVE_RANGE_M {
5129 return None;
5130 }
5131 Some(WorkerGiveTargetOption {
5132 instance_id: w.instance_id.clone(),
5133 label: w.label.clone(),
5134 distance_m: dist,
5135 })
5136 })
5137 .collect();
5138 options.sort_by(|a, b| {
5139 a.distance_m
5140 .partial_cmp(&b.distance_m)
5141 .unwrap_or(std::cmp::Ordering::Equal)
5142 });
5143 options
5144 }
5145
5146 pub fn close_worker_give_target_picker(&mut self) {
5147 self.state.show_worker_give_target_picker = false;
5148 self.state.worker_give_target_picker = None;
5149 self.state.worker_give_target_picker_index = 0;
5150 }
5151
5152 pub fn worker_give_target_picker_move(&mut self, delta: i32) {
5153 let Some(picker) = &self.state.worker_give_target_picker else {
5154 return;
5155 };
5156 let n = picker.options.len();
5157 if n == 0 {
5158 return;
5159 }
5160 let idx = self.state.worker_give_target_picker_index as i32;
5161 self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
5162 }
5163
5164 pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
5165 let Some(picker) = self.state.worker_give_target_picker.clone() else {
5166 anyhow::bail!("give target picker not open");
5167 };
5168 let Some(opt) = picker
5169 .options
5170 .get(self.state.worker_give_target_picker_index)
5171 .cloned()
5172 else {
5173 anyhow::bail!("no worker selected");
5174 };
5175 let Some(worker) = self
5176 .state
5177 .hired_workers
5178 .iter()
5179 .find(|w| w.instance_id == opt.instance_id)
5180 .cloned()
5181 else {
5182 self.close_worker_give_target_picker();
5183 anyhow::bail!("worker no longer hired");
5184 };
5185 self.give_item_to_worker(
5186 &worker.instance_id,
5187 &worker.label,
5188 worker.x,
5189 worker.y,
5190 picker.item_instance_id,
5191 &picker.item_label,
5192 picker.quantity,
5193 )
5194 .await?;
5195 self.close_worker_give_target_picker();
5196 Ok(())
5197 }
5198
5199 pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
5201 self.open_worker_give_target_picker()
5202 }
5203
5204 pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
5206 let Some(worker) = self
5207 .state
5208 .hired_workers
5209 .get(self.state.workers_menu_index)
5210 .cloned()
5211 else {
5212 anyhow::bail!("select a hired worker first");
5213 };
5214 let (px, py, _) = self.state.player_position_with_z();
5215 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
5216 if dist > WORKER_GIVE_RANGE_M {
5217 anyhow::bail!(
5218 "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
5219 worker.label
5220 );
5221 }
5222 let options = self.state.giveable_inventory_options();
5223 if options.is_empty() {
5224 anyhow::bail!("nothing in inventory to give");
5225 }
5226 self.state.worker_give_picker = Some(WorkerGivePicker {
5227 worker_instance_id: worker.instance_id,
5228 worker_label: worker.label,
5229 options,
5230 });
5231 self.state.worker_give_picker_index = 0;
5232 self.state.show_worker_give_picker = true;
5233 Ok(())
5234 }
5235
5236 pub fn close_worker_give_picker(&mut self) {
5237 self.state.show_worker_give_picker = false;
5238 self.state.worker_give_picker = None;
5239 self.state.worker_give_picker_index = 0;
5240 }
5241
5242 pub fn worker_give_picker_move(&mut self, delta: i32) {
5243 let Some(picker) = &self.state.worker_give_picker else {
5244 return;
5245 };
5246 let n = picker.options.len();
5247 if n == 0 {
5248 return;
5249 }
5250 let idx = self.state.worker_give_picker_index as i32;
5251 self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
5252 }
5253
5254 pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
5256 let Some(picker) = self.state.worker_give_picker.clone() else {
5257 anyhow::bail!("give picker not open");
5258 };
5259 let Some(opt) = picker.options.get(self.state.worker_give_picker_index).cloned() else {
5260 anyhow::bail!("no item selected");
5261 };
5262 let Some(worker) = self
5263 .state
5264 .hired_workers
5265 .iter()
5266 .find(|w| w.instance_id == picker.worker_instance_id)
5267 .cloned()
5268 else {
5269 self.close_worker_give_picker();
5270 anyhow::bail!("worker no longer hired");
5271 };
5272 self.give_item_to_worker(
5273 &worker.instance_id,
5274 &worker.label,
5275 worker.x,
5276 worker.y,
5277 opt.item_instance_id,
5278 &opt.label,
5279 None,
5280 )
5281 .await?;
5282 let options = self.state.giveable_inventory_options();
5284 if options.is_empty() {
5285 self.close_worker_give_picker();
5286 } else {
5287 self.state.worker_give_picker = Some(WorkerGivePicker {
5288 worker_instance_id: picker.worker_instance_id,
5289 worker_label: picker.worker_label,
5290 options,
5291 });
5292 if self.state.worker_give_picker_index
5293 >= self
5294 .state
5295 .worker_give_picker
5296 .as_ref()
5297 .map(|p| p.options.len())
5298 .unwrap_or(0)
5299 {
5300 self.state.worker_give_picker_index = self
5301 .state
5302 .worker_give_picker
5303 .as_ref()
5304 .map(|p| p.options.len().saturating_sub(1))
5305 .unwrap_or(0);
5306 }
5307 }
5308 Ok(())
5309 }
5310
5311 pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
5313 let Some(worker) = self
5314 .state
5315 .hired_workers
5316 .get(self.state.workers_menu_index)
5317 .cloned()
5318 else {
5319 anyhow::bail!("select a hired worker first");
5320 };
5321 let (px, py, _) = self.state.player_position_with_z();
5322 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
5323 if dist > WORKER_GIVE_RANGE_M {
5324 anyhow::bail!(
5325 "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
5326 worker.label
5327 );
5328 }
5329 let options = self.state.teachable_blueprint_options(&worker);
5330 if options.is_empty() {
5331 anyhow::bail!("no recipes you know that {} still needs", worker.label);
5332 }
5333 self.state.worker_teach_picker = Some(WorkerTeachPicker {
5334 worker_instance_id: worker.instance_id,
5335 worker_label: worker.label,
5336 worker_level: worker.level,
5337 options,
5338 });
5339 self.state.worker_teach_picker_index = 0;
5340 self.state.show_worker_teach_picker = true;
5341 Ok(())
5342 }
5343
5344 pub fn close_worker_teach_picker(&mut self) {
5345 self.state.show_worker_teach_picker = false;
5346 self.state.worker_teach_picker = None;
5347 self.state.worker_teach_picker_index = 0;
5348 }
5349
5350 pub fn worker_teach_picker_move(&mut self, delta: i32) {
5351 let Some(picker) = &self.state.worker_teach_picker else {
5352 return;
5353 };
5354 let n = picker.options.len();
5355 if n == 0 {
5356 return;
5357 }
5358 let idx = self.state.worker_teach_picker_index as i32;
5359 self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
5360 }
5361
5362 pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
5363 let Some(picker) = self.state.worker_teach_picker.clone() else {
5364 anyhow::bail!("teach picker not open");
5365 };
5366 let Some(opt) = picker.options.get(self.state.worker_teach_picker_index).cloned() else {
5367 anyhow::bail!("nothing selected");
5368 };
5369 if !opt.level_ok {
5370 anyhow::bail!(
5371 "{} needs level {} (is level {})",
5372 picker.worker_label,
5373 opt.min_level,
5374 opt.worker_level
5375 );
5376 }
5377 if !opt.can_afford {
5378 anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
5379 }
5380 let Some(worker) = self
5381 .state
5382 .hired_workers
5383 .iter()
5384 .find(|w| w.instance_id == picker.worker_instance_id)
5385 .cloned()
5386 else {
5387 anyhow::bail!("worker gone");
5388 };
5389 let (px, py, _) = self.state.player_position_with_z();
5390 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
5391 if dist > WORKER_GIVE_RANGE_M {
5392 anyhow::bail!("worker {} too far — stand next to them", worker.label);
5393 }
5394 self.seq += 1;
5395 self.session
5396 .submit_intent(Intent::TeachWorkerBlueprint {
5397 entity_id: self.state.entity_id,
5398 worker_instance_id: picker.worker_instance_id.clone(),
5399 blueprint_id: opt.blueprint_id.clone(),
5400 seq: self.seq,
5401 })
5402 .await?;
5403 self.state.intents_sent += 1;
5404 self.state.push_log(format!(
5405 "Teaching {} to {} ({} cp)",
5406 opt.label, picker.worker_label, opt.cost_copper
5407 ));
5408 self.close_worker_teach_picker();
5409 Ok(())
5410 }
5411
5412 async fn give_item_to_worker(
5413 &mut self,
5414 worker_instance_id: &str,
5415 worker_label: &str,
5416 worker_x: f32,
5417 worker_y: f32,
5418 item_instance_id: uuid::Uuid,
5419 item_label: &str,
5420 quantity: Option<u32>,
5421 ) -> anyhow::Result<()> {
5422 let (px, py, _) = self.state.player_position_with_z();
5423 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
5424 if dist > WORKER_GIVE_RANGE_M {
5425 anyhow::bail!("worker {worker_label} too far — stand next to them");
5426 }
5427 self.seq += 1;
5428 self.session
5429 .submit_intent(Intent::GiveWorkerItem {
5430 entity_id: self.state.entity_id,
5431 worker_instance_id: worker_instance_id.to_string(),
5432 item_instance_id,
5433 quantity,
5434 seq: self.seq,
5435 })
5436 .await?;
5437 self.state.intents_sent += 1;
5438 self.state
5439 .push_log(format!("Gave {item_label} to {worker_label}"));
5440 Ok(())
5441 }
5442
5443 pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
5445 let Some(worker) = self
5446 .state
5447 .hired_workers
5448 .get(self.state.workers_menu_index)
5449 .cloned()
5450 else {
5451 anyhow::bail!("select a hired worker first");
5452 };
5453 let (px, py, _) = self.state.player_position_with_z();
5454 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
5455 if dist > WORKER_GIVE_RANGE_M {
5456 anyhow::bail!(
5457 "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
5458 worker.label
5459 );
5460 }
5461 let options = Self::worker_inventory_options(&worker);
5462 if options.is_empty() {
5463 anyhow::bail!("{} isn't carrying anything", worker.label);
5464 }
5465 self.state.worker_take_picker = Some(WorkerTakePicker {
5466 worker_instance_id: worker.instance_id,
5467 worker_label: worker.label,
5468 options,
5469 });
5470 self.state.worker_take_picker_index = 0;
5471 self.state.show_worker_take_picker = true;
5472 Ok(())
5473 }
5474
5475 fn worker_inventory_options(
5476 worker: &flatland_protocol::HiredWorkerView,
5477 ) -> Vec<WorkerGiveOption> {
5478 worker
5479 .inventory
5480 .iter()
5481 .filter_map(|stack| {
5482 let item_instance_id = stack.item_instance_id?;
5483 let label = stack
5484 .display_name
5485 .clone()
5486 .unwrap_or_else(|| stack.template_id.clone());
5487 let label = if stack.quantity > 1 {
5488 format!("{label} ×{}", stack.quantity)
5489 } else {
5490 label
5491 };
5492 Some(WorkerGiveOption {
5493 item_instance_id,
5494 label,
5495 quantity: stack.quantity,
5496 template_id: stack.template_id.clone(),
5497 })
5498 })
5499 .collect()
5500 }
5501
5502 pub fn close_worker_take_picker(&mut self) {
5503 self.state.show_worker_take_picker = false;
5504 self.state.worker_take_picker = None;
5505 self.state.worker_take_picker_index = 0;
5506 }
5507
5508 pub fn worker_take_picker_move(&mut self, delta: i32) {
5509 let Some(picker) = &self.state.worker_take_picker else {
5510 return;
5511 };
5512 let n = picker.options.len();
5513 if n == 0 {
5514 return;
5515 }
5516 let idx = self.state.worker_take_picker_index as i32;
5517 self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
5518 }
5519
5520 pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
5521 let Some(picker) = self.state.worker_take_picker.clone() else {
5522 anyhow::bail!("take picker not open");
5523 };
5524 let Some(opt) = picker.options.get(self.state.worker_take_picker_index).cloned() else {
5525 anyhow::bail!("no item selected");
5526 };
5527 let Some(worker) = self
5528 .state
5529 .hired_workers
5530 .iter()
5531 .find(|w| w.instance_id == picker.worker_instance_id)
5532 .cloned()
5533 else {
5534 self.close_worker_take_picker();
5535 anyhow::bail!("worker no longer hired");
5536 };
5537 self.take_item_from_worker(
5538 &worker.instance_id,
5539 &worker.label,
5540 worker.x,
5541 worker.y,
5542 opt.item_instance_id,
5543 &opt.label,
5544 None,
5545 )
5546 .await?;
5547 if let Some(w) = self
5551 .state
5552 .hired_workers
5553 .iter()
5554 .find(|w| w.instance_id == picker.worker_instance_id)
5555 {
5556 let options = Self::worker_inventory_options(w)
5557 .into_iter()
5558 .filter(|o| o.item_instance_id != opt.item_instance_id)
5559 .collect::<Vec<_>>();
5560 if options.is_empty() {
5561 self.close_worker_take_picker();
5562 } else {
5563 self.state.worker_take_picker = Some(WorkerTakePicker {
5564 worker_instance_id: picker.worker_instance_id,
5565 worker_label: picker.worker_label,
5566 options,
5567 });
5568 self.state.worker_take_picker_index = self
5569 .state
5570 .worker_take_picker_index
5571 .min(
5572 self.state
5573 .worker_take_picker
5574 .as_ref()
5575 .map(|p| p.options.len().saturating_sub(1))
5576 .unwrap_or(0),
5577 );
5578 }
5579 } else {
5580 self.close_worker_take_picker();
5581 }
5582 Ok(())
5583 }
5584
5585 async fn take_item_from_worker(
5586 &mut self,
5587 worker_instance_id: &str,
5588 worker_label: &str,
5589 worker_x: f32,
5590 worker_y: f32,
5591 item_instance_id: uuid::Uuid,
5592 item_label: &str,
5593 quantity: Option<u32>,
5594 ) -> anyhow::Result<()> {
5595 let (px, py, _) = self.state.player_position_with_z();
5596 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
5597 if dist > WORKER_GIVE_RANGE_M {
5598 anyhow::bail!("worker {worker_label} too far — stand next to them");
5599 }
5600 self.seq += 1;
5601 self.session
5602 .submit_intent(Intent::TakeWorkerItem {
5603 entity_id: self.state.entity_id,
5604 worker_instance_id: worker_instance_id.to_string(),
5605 item_instance_id,
5606 quantity,
5607 seq: self.seq,
5608 })
5609 .await?;
5610 self.state.intents_sent += 1;
5611 self.state
5612 .push_log(format!("Took {item_label} from {worker_label}"));
5613 Ok(())
5614 }
5615
5616 pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
5617 if !self.state.has_worker_lodging() {
5618 anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
5619 }
5620 self.seq += 1;
5621 self.session
5622 .submit_intent(Intent::HireWorker {
5623 entity_id: self.state.entity_id,
5624 def_id: "worker_laborer".into(),
5625 wage_copper_per_interval: 8,
5626 lodging_container_id: None,
5627 job_yaml: None,
5628 seq: self.seq,
5629 })
5630 .await?;
5631 self.state.intents_sent += 1;
5632 Ok(())
5633 }
5634
5635 pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
5636 let Some(worker) = self
5637 .state
5638 .hired_workers
5639 .get(self.state.workers_menu_index)
5640 .cloned()
5641 else {
5642 anyhow::bail!("select a hired worker first");
5643 };
5644 let lodging = worker.lodging_container_id.clone().or_else(|| {
5645 crate::worker_route_editor::owned_lodging_container_ids(
5646 &self.state.placed_containers,
5647 self.state.character_id,
5648 )
5649 .into_iter()
5650 .next()
5651 .map(|(id, _)| id)
5652 });
5653 let label = worker.label.clone();
5654 let editor = if let Some(route) = &worker.route {
5655 crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
5656 worker.instance_id,
5657 worker.label,
5658 route,
5659 lodging,
5660 )
5661 } else {
5662 crate::worker_route_editor::WorkerRouteEditorState::new(
5663 worker.instance_id,
5664 worker.label,
5665 lodging,
5666 )
5667 };
5668 self.state.worker_route_editor = Some(editor);
5669 self.state.show_workers_menu = false;
5670 self.state.push_log(format!(
5671 "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
5672 ));
5673 Ok(())
5674 }
5675
5676 pub fn close_worker_route_editor(&mut self) {
5677 self.state.worker_route_editor = None;
5678 }
5679
5680 pub fn worker_route_editor_toggle_panel(&mut self) {
5681 if let Some(ed) = self.state.worker_route_editor.as_mut() {
5682 ed.toggle_panel_collapsed();
5683 }
5684 }
5685
5686 pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
5687 let n = {
5688 let Some(ed) = self.state.worker_route_editor.as_mut() else {
5689 return;
5690 };
5691 ed.append_waypoint(x, y, z);
5692 ed.stop_count()
5693 };
5694 self.state
5695 .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
5696 }
5697
5698 fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
5701 let (px, py, _) = self.state.player_position_with_z();
5702 crate::worker_route_editor::owned_container_candidates(
5703 &self.state.placed_containers,
5704 self.state.character_id,
5705 px,
5706 py,
5707 )
5708 }
5709
5710 fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
5711 let (px, py, _) = self.state.player_position_with_z();
5712 crate::worker_route_editor::node_candidates(&self.state.resource_nodes, px, py)
5713 }
5714
5715 fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
5716 let (px, py, _) = self.state.player_position_with_z();
5717 crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
5718 }
5719
5720 fn re_template_candidates(&self) -> Vec<String> {
5721 let mut extra = Vec::new();
5722 if let Some(ed) = self.state.worker_route_editor.as_ref() {
5723 for stop in &ed.stops {
5724 match stop {
5725 crate::worker_route_editor::WorkerRouteStop::DepositAt {
5726 filter: Some(filter),
5727 ..
5728 } => extra.extend(filter.iter().cloned()),
5729 crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
5730 extra.push(template.clone());
5731 }
5732 crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
5733 if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint) {
5734 extra.push(bp.output.clone());
5735 for input in &bp.inputs {
5736 extra.push(input.template_id.clone());
5737 }
5738 }
5739 }
5740 crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
5741 for it in items {
5742 extra.push(it.template.clone());
5743 }
5744 }
5745 _ => {}
5746 }
5747 }
5748 if let Some(worker) = self
5750 .state
5751 .hired_workers
5752 .iter()
5753 .find(|w| w.instance_id == ed.worker_instance_id)
5754 {
5755 for recipe in &worker.known_blueprint_ids {
5756 if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
5757 extra.push(bp.output.clone());
5758 }
5759 }
5760 }
5761 }
5762 crate::worker_route_editor::route_item_template_candidates(
5763 &self.state.placed_containers,
5764 self.state.character_id,
5765 &self.state.inventory,
5766 &self.state.blueprints,
5767 &self.state.resource_nodes,
5768 &extra,
5769 )
5770 }
5771
5772 fn re_blueprint_ids(&self) -> Vec<String> {
5773 let worker_known: Option<&[String]> = self
5774 .state
5775 .worker_route_editor
5776 .as_ref()
5777 .and_then(|ed| {
5778 self.state
5779 .hired_workers
5780 .iter()
5781 .find(|w| w.instance_id == ed.worker_instance_id)
5782 })
5783 .map(|w| w.known_blueprint_ids.as_slice());
5784 crate::worker_route_editor::worker_craft_blueprint_ids(
5785 &self.state.blueprints,
5786 worker_known,
5787 )
5788 }
5789
5790 fn re_bed_candidates(&self) -> Vec<(String, String)> {
5791 crate::worker_route_editor::owned_lodging_container_ids(
5792 &self.state.placed_containers,
5793 self.state.character_id,
5794 )
5795 }
5796
5797 fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
5798 self.state
5799 .placed_containers
5800 .iter()
5801 .find(|c| c.id == container_id)
5802 .map(|c| c.contents.clone())
5803 .unwrap_or_default()
5804 }
5805
5806 pub fn re_sheet_row_count(&self) -> usize {
5810 use crate::worker_route_editor::RouteEditorSheet as S;
5811 let Some(ed) = self.state.worker_route_editor.as_ref() else {
5812 return 0;
5813 };
5814 match &ed.sheet {
5815 S::Stops => ed.stops.len(),
5816 S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
5817 S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
5818 S::WaypointMapPick => 0,
5819 S::HarvestPicker { .. } => self.re_node_candidates().len(),
5820 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
5821 self.re_container_candidates().len()
5822 }
5823 S::WithdrawItems { lines, .. } => lines.len() + 1, S::DepositFilter { rows, .. } => rows.len() + 1, S::SellNpcs { .. } => self.re_npc_candidates().len() + 1, S::SellItem { templates, .. } => templates.len() + 1, S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
5828 S::WaitEntry { .. } => 1,
5829 S::BedPicker { .. } => self.re_bed_candidates().len(),
5830 }
5831 }
5832
5833 pub fn re_sheet_index(&self) -> usize {
5835 use crate::worker_route_editor::RouteEditorSheet as S;
5836 let Some(ed) = self.state.worker_route_editor.as_ref() else {
5837 return 0;
5838 };
5839 match &ed.sheet {
5840 S::AddMenu { index }
5841 | S::WaypointMenu { index }
5842 | S::HarvestPicker { index }
5843 | S::WithdrawContainers { index }
5844 | S::DepositContainers { index }
5845 | S::SellNpcs { index }
5846 | S::CraftBlueprint { index }
5847 | S::BedPicker { index }
5848 | S::WithdrawItems { index, .. }
5849 | S::DepositFilter { index, .. }
5850 | S::SellItem { index, .. } => *index,
5851 _ => 0,
5852 }
5853 }
5854
5855 pub fn re_sheet_move(&mut self, delta: i32) {
5857 use crate::worker_route_editor::RouteEditorSheet as S;
5858 let count = self.re_sheet_row_count();
5859 if count == 0 {
5860 return;
5861 }
5862 let cur = self.re_sheet_index() as i32;
5863 let next = (cur + delta).rem_euclid(count as i32) as usize;
5864 let Some(ed) = self.state.worker_route_editor.as_mut() else {
5865 return;
5866 };
5867 match &mut ed.sheet {
5868 S::AddMenu { index }
5869 | S::WaypointMenu { index }
5870 | S::HarvestPicker { index }
5871 | S::WithdrawContainers { index }
5872 | S::DepositContainers { index }
5873 | S::SellNpcs { index }
5874 | S::CraftBlueprint { index }
5875 | S::BedPicker { index }
5876 | S::WithdrawItems { index, .. }
5877 | S::DepositFilter { index, .. }
5878 | S::SellItem { index, .. } => *index = next,
5879 _ => {}
5880 }
5881 }
5882
5883 pub fn re_sheet_adjust(&mut self, delta: i32) {
5885 use crate::worker_route_editor::RouteEditorSheet as S;
5886 let index = self.re_sheet_index();
5887 let Some(ed) = self.state.worker_route_editor.as_mut() else {
5888 return;
5889 };
5890 match &mut ed.sheet {
5891 S::WithdrawItems { lines, .. } => {
5892 if let Some(line) = lines.get_mut(index) {
5893 line.adjust_qty(delta);
5894 }
5895 }
5896 S::WaitEntry { ticks } => {
5897 *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
5898 }
5899 _ => {}
5900 }
5901 }
5902
5903 pub fn re_sheet_back(&mut self) {
5904 let Some(ed) = self.state.worker_route_editor.as_mut() else {
5905 return;
5906 };
5907 use crate::worker_route_editor::RouteEditorSheet as S;
5908 let was_editing = ed.editing_index.is_some();
5909 let from_top_picker = matches!(
5910 ed.sheet,
5911 S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
5912 );
5913 ed.sheet_back();
5914 if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
5915 self.state
5917 .push_log("Route: left edit sheet — press s to save current stops".to_string());
5918 }
5919 }
5920
5921 pub fn re_at_root_sheet(&self) -> bool {
5923 self.state
5924 .worker_route_editor
5925 .as_ref()
5926 .is_some_and(|ed| matches!(ed.sheet, crate::worker_route_editor::RouteEditorSheet::Stops))
5927 }
5928
5929 pub fn re_open_add_menu(&mut self) {
5930 if let Some(ed) = self.state.worker_route_editor.as_mut() {
5931 ed.open_add_menu();
5932 }
5933 }
5934
5935 pub fn re_open_bed_picker(&mut self) {
5936 let beds = self.re_bed_candidates();
5937 if beds.is_empty() {
5938 self.state
5939 .push_log("Route: place a camp bed first".to_string());
5940 return;
5941 }
5942 let current = self
5943 .state
5944 .worker_route_editor
5945 .as_ref()
5946 .and_then(|ed| ed.lodging_container_id.clone());
5947 let index = current
5948 .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
5949 .unwrap_or(0);
5950 self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
5951 }
5952
5953 fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
5954 if let Some(ed) = self.state.worker_route_editor.as_mut() {
5955 ed.open_sheet(sheet);
5956 }
5957 }
5958
5959 fn re_confirm_stop(
5961 &mut self,
5962 stop: crate::worker_route_editor::WorkerRouteStop,
5963 what: String,
5964 ) {
5965 let appended = self
5966 .state
5967 .worker_route_editor
5968 .as_mut()
5969 .is_some_and(|ed| ed.confirm_stop(stop));
5970 if appended {
5971 self.state.push_log(format!("Route: + {what}"));
5972 } else {
5973 self.state
5974 .push_log(format!("Route: {what} already in route — selected it"));
5975 }
5976 }
5977
5978 fn re_open_withdraw_items(&mut self, container_id: String) {
5979 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
5980 let contents = self.re_container_contents(&container_id);
5981 let existing = self
5985 .state
5986 .worker_route_editor
5987 .as_ref()
5988 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
5989 .and_then(|stop| match stop {
5990 WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
5991 _ => None,
5992 })
5993 .unwrap_or_default();
5994 let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
5995 if let Some(ed) = self.state.worker_route_editor.as_mut() {
5998 let _ = ed.retarget_withdraw_container(container_id.clone());
5999 }
6000 self.re_open_sheet(S::WithdrawItems {
6001 container_id,
6002 lines,
6003 index: 0,
6004 });
6005 }
6006
6007 fn re_withdraw_items_activate(&mut self, index: usize) {
6008 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
6009 enum Outcome {
6010 Cycled,
6011 Confirmed(String),
6012 Empty,
6013 }
6014 let outcome = {
6015 let Some(ed) = self.state.worker_route_editor.as_mut() else {
6016 return;
6017 };
6018 let S::WithdrawItems {
6019 container_id,
6020 lines,
6021 index: sheet_index,
6022 } = &mut ed.sheet
6023 else {
6024 return;
6025 };
6026 *sheet_index = index;
6027 if index < lines.len() {
6028 lines[index].cycle();
6029 Outcome::Cycled
6030 } else {
6031 let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
6032 if items.is_empty() {
6033 Outcome::Empty
6034 } else {
6035 let stop = WorkerRouteStop::WithdrawFrom {
6036 container_id: container_id.clone(),
6037 items,
6038 };
6039 let summary = stop.summary();
6040 ed.confirm_stop(stop);
6041 Outcome::Confirmed(summary)
6042 }
6043 }
6044 };
6045 match outcome {
6046 Outcome::Cycled => {}
6047 Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
6048 Outcome::Empty => self
6049 .state
6050 .push_log("Route: pick at least one item (Space/Enter toggles All/qty)".to_string()),
6051 }
6052 }
6053
6054 fn re_open_deposit_filter(&mut self, container_id: String) {
6055 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
6056 let existing_filter = self
6058 .state
6059 .worker_route_editor
6060 .as_ref()
6061 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
6062 .and_then(|stop| match stop {
6063 WorkerRouteStop::DepositAt { filter, .. } => {
6064 Some(filter.clone().unwrap_or_default())
6065 }
6066 _ => None,
6067 });
6068 let mut candidates = self.re_template_candidates();
6069 if let Some(ref chosen) = existing_filter {
6070 for t in chosen {
6071 if !candidates.iter().any(|c| c == t) {
6072 candidates.push(t.clone());
6073 }
6074 }
6075 candidates.sort();
6076 candidates.dedup();
6077 }
6078 let rows: Vec<(String, bool)> = match existing_filter {
6079 Some(chosen) => candidates
6080 .iter()
6081 .map(|t| (t.clone(), chosen.contains(t)))
6082 .collect(),
6083 None => candidates.into_iter().map(|t| (t, false)).collect(),
6084 };
6085 if let Some(ed) = self.state.worker_route_editor.as_mut() {
6086 let _ = ed.retarget_deposit_container(container_id.clone());
6087 }
6088 self.re_open_sheet(S::DepositFilter {
6089 container_id,
6090 rows,
6091 index: 0,
6092 });
6093 }
6094
6095 fn re_deposit_filter_activate(&mut self, index: usize) {
6096 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
6097 let mut confirmed: Option<String> = None;
6098 {
6099 let Some(ed) = self.state.worker_route_editor.as_mut() else {
6100 return;
6101 };
6102 let S::DepositFilter {
6103 container_id,
6104 rows,
6105 index: sheet_index,
6106 } = &mut ed.sheet
6107 else {
6108 return;
6109 };
6110 *sheet_index = index;
6111 if index < rows.len() {
6112 rows[index].1 = !rows[index].1;
6113 } else {
6114 let chosen: Vec<String> = rows
6116 .iter()
6117 .filter(|(_, on)| *on)
6118 .map(|(t, _)| t.clone())
6119 .collect();
6120 let filter = if chosen.is_empty() { None } else { Some(chosen) };
6121 let stop = WorkerRouteStop::DepositAt {
6122 container_id: container_id.clone(),
6123 filter,
6124 };
6125 confirmed = Some(stop.summary());
6126 ed.confirm_stop(stop);
6127 }
6128 }
6129 if let Some(what) = confirmed {
6130 self.state.push_log(format!("Route: + {what}"));
6131 }
6132 }
6133
6134 fn re_open_sell_item(&mut self, npc_id: Option<String>) {
6135 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
6136 let templates = self.re_template_candidates();
6137 if templates.is_empty() {
6138 self.state.push_log(
6139 "Route: no item templates available — learn a craft recipe or place a harvest node first"
6140 .to_string(),
6141 );
6142 return;
6143 }
6144 let (pre_npc, pre_template, pre_all) = self
6146 .state
6147 .worker_route_editor
6148 .as_ref()
6149 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
6150 .and_then(|stop| match stop {
6151 WorkerRouteStop::TradeWith {
6152 npc_id,
6153 template,
6154 sell_all,
6155 } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
6156 _ => None,
6157 })
6158 .unwrap_or((None, None, true));
6159 let npc_id = npc_id.or(pre_npc);
6160 let index = pre_template
6161 .and_then(|t| templates.iter().position(|x| x == &t))
6162 .map(|i| i + 1) .unwrap_or(1);
6164 self.re_open_sheet(S::SellItem {
6165 npc_id,
6166 templates,
6167 index,
6168 sell_all: pre_all,
6169 });
6170 }
6171
6172 fn re_sell_item_activate(&mut self, index: usize) {
6173 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
6174 let mut confirmed: Option<String> = None;
6175 {
6176 let Some(ed) = self.state.worker_route_editor.as_mut() else {
6177 return;
6178 };
6179 let S::SellItem {
6180 npc_id,
6181 templates,
6182 index: sheet_index,
6183 sell_all,
6184 } = &mut ed.sheet
6185 else {
6186 return;
6187 };
6188 *sheet_index = index;
6189 if index == 0 {
6190 *sell_all = !*sell_all;
6191 } else if let Some(template) = templates.get(index - 1).cloned() {
6192 let stop = WorkerRouteStop::TradeWith {
6193 npc_id: npc_id.clone(),
6194 template,
6195 sell_all: *sell_all,
6196 };
6197 confirmed = Some(stop.summary());
6198 ed.confirm_stop(stop);
6199 }
6200 }
6201 if let Some(what) = confirmed {
6202 self.state.push_log(format!("Route: + {what}"));
6203 }
6204 }
6205
6206 pub fn re_edit_selected_stop(&mut self) {
6208 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
6209 let Some(stop) = self
6210 .state
6211 .worker_route_editor
6212 .as_ref()
6213 .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
6214 else {
6215 self.state
6216 .push_log("Route: no stop selected — press a to add one".to_string());
6217 return;
6218 };
6219 if let Some(ed) = self.state.worker_route_editor.as_mut() {
6220 ed.begin_edit_selected();
6221 }
6222 match stop {
6223 WorkerRouteStop::Waypoint { .. } => {
6224 self.re_open_sheet(S::WaypointMenu { index: 0 });
6225 }
6226 WorkerRouteStop::HarvestNode { node_id } => {
6227 let nodes = self.re_node_candidates();
6228 let index = nodes.iter().position(|n| n.id == node_id).unwrap_or(0);
6229 if nodes.is_empty() {
6230 self.re_cancel_edit();
6231 self.state
6232 .push_log("Route: no harvestable nodes visible to retarget".to_string());
6233 } else {
6234 self.re_open_sheet(S::HarvestPicker { index });
6235 }
6236 }
6237 WorkerRouteStop::WithdrawFrom { container_id, .. } => {
6238 let containers = self.re_container_candidates();
6241 if containers.is_empty() {
6242 self.re_cancel_edit();
6243 self.state
6244 .push_log("Route: place a storage chest first".to_string());
6245 } else {
6246 let index = containers
6247 .iter()
6248 .position(|c| c.id == container_id)
6249 .unwrap_or(0);
6250 self.re_open_sheet(S::WithdrawContainers { index });
6251 }
6252 }
6253 WorkerRouteStop::DepositAt { container_id, .. } => {
6254 let containers = self.re_container_candidates();
6255 if containers.is_empty() {
6256 self.re_cancel_edit();
6257 self.state
6258 .push_log("Route: place a storage chest first".to_string());
6259 } else {
6260 let index = containers
6261 .iter()
6262 .position(|c| c.id == container_id)
6263 .unwrap_or(0);
6264 self.re_open_sheet(S::DepositContainers { index });
6265 }
6266 }
6267 WorkerRouteStop::TradeWith { npc_id, .. } => {
6268 let npcs = self.re_npc_candidates();
6269 let index = npc_id
6271 .as_ref()
6272 .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
6273 .unwrap_or(0);
6274 self.re_open_sheet(S::SellNpcs { index });
6275 }
6276 WorkerRouteStop::CraftAt { blueprint, .. } => {
6277 let bps = self.re_blueprint_ids();
6278 let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
6279 if bps.is_empty() {
6280 self.re_cancel_edit();
6281 self.state
6282 .push_log("Route: no known blueprints to retarget".to_string());
6283 } else {
6284 self.re_open_sheet(S::CraftBlueprint { index });
6285 }
6286 }
6287 WorkerRouteStop::RestIfNeeded => {
6288 self.re_cancel_edit();
6289 self.state
6290 .push_log("Route: rest has no settings (change the bed with l)".to_string());
6291 }
6292 WorkerRouteStop::Wait { wait_ticks } => {
6293 self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
6294 }
6295 }
6296 }
6297
6298 fn re_cancel_edit(&mut self) {
6299 if let Some(ed) = self.state.worker_route_editor.as_mut() {
6300 ed.editing_index = None;
6301 }
6302 }
6303
6304 pub fn worker_route_editor_ui_click(
6307 &mut self,
6308 click: crate::worker_route_editor::RouteEditorClick,
6309 ) {
6310 use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
6311 match click {
6312 RouteEditorClick::SelectStop(i) => {
6313 if let Some(ed) = self.state.worker_route_editor.as_mut() {
6314 ed.sheet = S::Stops;
6315 ed.select_stop(i);
6316 }
6317 }
6318 RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
6319 RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
6320 RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
6321 }
6322 }
6323
6324 pub fn re_sheet_row_activate(&mut self, row: usize) {
6326 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
6327 let Some(sheet) = self
6328 .state
6329 .worker_route_editor
6330 .as_ref()
6331 .map(|ed| ed.sheet.clone())
6332 else {
6333 return;
6334 };
6335 match sheet {
6336 S::Stops => {
6337 if let Some(ed) = self.state.worker_route_editor.as_mut() {
6338 ed.select_stop(row);
6339 }
6340 }
6341 S::AddMenu { .. } => match row {
6342 0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
6343 1 => {
6344 if self.re_node_candidates().is_empty() {
6345 self.state
6346 .push_log("Route: no harvestable nodes visible in this region".to_string());
6347 } else {
6348 self.re_open_sheet(S::HarvestPicker { index: 0 });
6349 }
6350 }
6351 2 | 3 => {
6352 if self.re_container_candidates().is_empty() {
6353 self.state
6354 .push_log("Route: place a storage chest first".to_string());
6355 } else if row == 2 {
6356 self.re_open_sheet(S::WithdrawContainers { index: 0 });
6357 } else {
6358 self.re_open_sheet(S::DepositContainers { index: 0 });
6359 }
6360 }
6361 4 => {
6362 if self.re_template_candidates().is_empty() {
6363 self.state.push_log(
6364 "Route: no item templates available — learn a craft recipe or place a harvest node first"
6365 .to_string(),
6366 );
6367 } else {
6368 self.re_open_sheet(S::SellNpcs { index: 0 });
6369 }
6370 }
6371 5 => {
6372 if self.re_blueprint_ids().is_empty() {
6373 self.state.push_log(
6374 "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
6375 .to_string(),
6376 );
6377 } else {
6378 self.re_open_sheet(S::CraftBlueprint { index: 0 });
6379 }
6380 }
6381 6 => self.re_confirm_stop(
6382 WorkerRouteStop::RestIfNeeded,
6383 "rest if needed".into(),
6384 ),
6385 7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
6386 _ => {}
6387 },
6388 S::WaypointMenu { .. } => match row {
6389 0 => {
6390 let (x, y, z) = self.state.player_position_with_z();
6391 let stop = WorkerRouteStop::Waypoint { x, y, z };
6392 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
6393 }
6394 1 => {
6395 self.re_open_sheet(S::WaypointMapPick);
6396 self.state.push_log("Route: click the map to place the waypoint (Esc to finish)".to_string());
6397 }
6398 _ => {}
6399 },
6400 S::HarvestPicker { .. } => {
6401 let nodes = self.re_node_candidates();
6402 if let Some(n) = nodes.get(row) {
6403 let stop = WorkerRouteStop::HarvestNode {
6404 node_id: n.id.clone(),
6405 };
6406 let label = n.label.clone();
6407 self.re_confirm_stop(stop, format!("harvest {label}"));
6408 }
6409 }
6410 S::WithdrawContainers { .. } => {
6411 let containers = self.re_container_candidates();
6412 if let Some(c) = containers.get(row) {
6413 let id = c.id.clone();
6414 self.re_open_withdraw_items(id);
6415 }
6416 }
6417 S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
6418 S::DepositContainers { .. } => {
6419 let containers = self.re_container_candidates();
6420 if let Some(c) = containers.get(row) {
6421 let id = c.id.clone();
6422 self.re_open_deposit_filter(id);
6423 }
6424 }
6425 S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
6426 S::SellNpcs { .. } => {
6427 let npcs = self.re_npc_candidates();
6428 let npc_id = if row == 0 {
6429 None
6430 } else {
6431 npcs.get(row - 1).map(|n| n.id.clone())
6432 };
6433 if row == 0 || npc_id.is_some() {
6434 self.re_open_sell_item(npc_id);
6435 }
6436 }
6437 S::SellItem { .. } => self.re_sell_item_activate(row),
6438 S::CraftBlueprint { .. } => {
6439 let bps = self.re_blueprint_ids();
6440 if let Some(bp) = bps.get(row) {
6441 let stop = WorkerRouteStop::CraftAt {
6442 device: "hand".into(),
6443 blueprint: bp.clone(),
6444 qty: None,
6445 };
6446 self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
6447 }
6448 }
6449 S::WaitEntry { ticks } => {
6450 let stop = WorkerRouteStop::Wait {
6451 wait_ticks: ticks,
6452 };
6453 self.re_confirm_stop(stop, format!("wait {ticks}t"));
6454 }
6455 S::BedPicker { .. } => {
6456 let beds = self.re_bed_candidates();
6457 if let Some((id, name)) = beds.get(row) {
6458 let (id, name) = (id.clone(), name.clone());
6459 if let Some(ed) = self.state.worker_route_editor.as_mut() {
6460 ed.lodging_container_id = Some(id.clone());
6461 ed.sheet = S::Stops;
6462 }
6463 self.state
6464 .push_log(format!("Route: rest bed set to {name}"));
6465 }
6466 }
6467 S::WaypointMapPick => {}
6468 }
6469 }
6470
6471 pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
6478 use crate::worker_route_editor as wre;
6479 use wre::RouteEditorSheet as S;
6480 if self.state.worker_route_editor.is_none() {
6481 return;
6482 }
6483 let sheet = self
6484 .state
6485 .worker_route_editor
6486 .as_ref()
6487 .map(|ed| ed.sheet.clone())
6488 .unwrap_or(S::Stops);
6489 match sheet {
6490 S::WaypointMapPick => {
6491 let (_, _, z) = self.state.player_position_with_z();
6492 let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
6493 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
6494 let editing = self
6496 .state
6497 .worker_route_editor
6498 .as_ref()
6499 .is_some_and(|ed| ed.editing_index.is_some());
6500 if !editing {
6501 if let Some(ed) = self.state.worker_route_editor.as_mut() {
6502 ed.sheet = S::WaypointMapPick;
6503 }
6504 }
6505 }
6506 S::HarvestPicker { .. } => {
6507 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
6508 let stop = wre::WorkerRouteStop::HarvestNode {
6509 node_id: node.id.clone(),
6510 };
6511 let label = node.label.clone();
6512 self.re_confirm_stop(stop, format!("harvest {label}"));
6513 }
6514 }
6515 S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
6516 if let Some(cid) = wre::pick_storage_container_at(
6518 &self.state.placed_containers,
6519 self.state.character_id,
6520 x,
6521 y,
6522 ) {
6523 self.re_open_withdraw_items(cid);
6524 }
6525 }
6526 S::DepositContainers { .. } | S::DepositFilter { .. } => {
6527 if let Some(cid) = wre::pick_storage_container_at(
6528 &self.state.placed_containers,
6529 self.state.character_id,
6530 x,
6531 y,
6532 ) {
6533 self.re_open_deposit_filter(cid);
6534 }
6535 }
6536 S::SellNpcs { .. } => {
6537 if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
6538 self.re_open_sell_item(Some(npc_id));
6539 }
6540 }
6541 S::SellItem { .. } => {
6542 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
6543 if let Some(ed) = self.state.worker_route_editor.as_mut() {
6544 if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
6545 *slot = Some(npc_id.clone());
6546 }
6547 }
6548 self.state
6549 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
6550 }
6551 }
6552 _ => self.worker_route_editor_quick_add_click(x, y),
6554 }
6555 }
6556
6557 fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
6561 use crate::worker_route_editor as wre;
6562 let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
6563 let dx = ax - bx;
6564 let dy = ay - by;
6565 (dx * dx + dy * dy).sqrt()
6566 };
6567
6568 let selected_stop_kind = self
6571 .state
6572 .worker_route_editor
6573 .as_ref()
6574 .and_then(|ed| ed.stops.get(ed.selected_stop_index))
6575 .map(|s| match s {
6576 wre::WorkerRouteStop::TradeWith { .. } => 1,
6577 wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
6578 _ => 0,
6579 })
6580 .unwrap_or(0);
6581 if selected_stop_kind == 1 {
6582 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
6583 if let Some(ed) = self.state.worker_route_editor.as_mut() {
6584 ed.set_selected_trade_npc(npc_id.clone());
6585 }
6586 self.state
6587 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
6588 return;
6589 }
6590 }
6591 if selected_stop_kind == 2 {
6592 if let Some(cid) = wre::pick_storage_container_at(
6593 &self.state.placed_containers,
6594 self.state.character_id,
6595 x,
6596 y,
6597 ) {
6598 let name = self
6599 .state
6600 .placed_containers
6601 .iter()
6602 .find(|c| c.id == cid)
6603 .map(|c| c.display_name.clone())
6604 .unwrap_or_else(|| "container".into());
6605 if let Some(ed) = self.state.worker_route_editor.as_mut() {
6606 ed.set_selected_withdraw_container(cid.clone());
6607 }
6608 self.state
6609 .push_log(format!("Route: withdraw source → {name}"));
6610 return;
6611 }
6612 }
6613
6614 enum Target {
6617 Bed(String),
6618 Container(String),
6619 Npc(String, String),
6620 Node(String, String),
6621 }
6622 let mut best: Option<(f32, u8, Target)> = None;
6623 let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
6624 let better = match best {
6625 None => true,
6626 Some((bd, brank, _)) => d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank),
6627 };
6628 if better {
6629 *best = Some((d, rank, t));
6630 }
6631 };
6632 if let Some(bed_id) = wre::pick_lodging_container_at(
6633 &self.state.placed_containers,
6634 self.state.character_id,
6635 x,
6636 y,
6637 ) {
6638 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
6639 let already_bed = self
6642 .state
6643 .worker_route_editor
6644 .as_ref()
6645 .is_some_and(|ed| ed.lodging_container_id.as_deref() == Some(bed_id.as_str()));
6646 if already_bed {
6647 consider(dist(x, y, c.x, c.y), 1, Target::Container(bed_id), &mut best);
6648 } else {
6649 consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
6650 }
6651 }
6652 }
6653 if let Some(cid) = wre::pick_storage_container_at(
6654 &self.state.placed_containers,
6655 self.state.character_id,
6656 x,
6657 y,
6658 ) {
6659 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
6660 consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
6661 }
6662 }
6663 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
6664 if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
6665 consider(
6666 dist(x, y, n.x, n.y),
6667 2,
6668 Target::Npc(npc_id, label),
6669 &mut best,
6670 );
6671 }
6672 }
6673 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
6674 let d = dist(x, y, node.x, node.y);
6675 consider(
6676 d,
6677 3,
6678 Target::Node(node.id.clone(), node.label.clone()),
6679 &mut best,
6680 );
6681 }
6682
6683 match best.map(|(_, _, t)| t) {
6684 Some(Target::Bed(bed_id)) => {
6685 let name = self
6686 .state
6687 .placed_containers
6688 .iter()
6689 .find(|c| c.id == bed_id)
6690 .map(|c| c.display_name.clone())
6691 .unwrap_or_else(|| "camp bed".into());
6692 if let Some(ed) = self.state.worker_route_editor.as_mut() {
6693 ed.lodging_container_id = Some(bed_id.clone());
6694 }
6695 self.state
6696 .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
6697 }
6698 Some(Target::Container(cid)) => {
6699 let name = self
6700 .state
6701 .placed_containers
6702 .iter()
6703 .find(|c| c.id == cid)
6704 .map(|c| c.display_name.clone())
6705 .unwrap_or_else(|| "container".into());
6706 let added = self
6707 .state
6708 .worker_route_editor
6709 .as_mut()
6710 .is_some_and(|ed| ed.append_deposit_at(&cid));
6711 if added {
6712 self.state
6713 .push_log(format!("Route: + deposit at {name} ({cid})"));
6714 } else {
6715 self.state.push_log(format!(
6716 "Route: {name} already in route — selected it (d to remove)"
6717 ));
6718 }
6719 }
6720 Some(Target::Npc(npc_id, label)) => {
6721 let template = self.re_template_candidates().into_iter().next();
6724 let Some(template) = template else {
6725 self.state.push_log("Route: no items in your storage to sell — stock a chest first".to_string());
6726 return;
6727 };
6728 let added = self
6729 .state
6730 .worker_route_editor
6731 .as_mut()
6732 .is_some_and(|ed| ed.append_trade_with(template.clone(), Some(npc_id.clone()), true));
6733 if added {
6734 self.state
6735 .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
6736 } else {
6737 self.state.push_log(format!(
6738 "Route: {label} already sells {template} — selected it (d to remove)"
6739 ));
6740 }
6741 }
6742 Some(Target::Node(id, label)) => {
6743 let added = self
6744 .state
6745 .worker_route_editor
6746 .as_mut()
6747 .is_some_and(|ed| ed.append_harvest_node(&id));
6748 if added {
6749 self.state
6750 .push_log(format!("Route: + harvest node {label} ({id})"));
6751 } else {
6752 self.state.push_log(format!(
6753 "Route: {label} already in route — selected it (d to remove)"
6754 ));
6755 }
6756 }
6757 None => {}
6758 }
6759 }
6760
6761 pub fn worker_route_editor_select(&mut self, delta: i32) {
6762 let Some(ed) = self.state.worker_route_editor.as_mut() else {
6763 return;
6764 };
6765 if ed.stops.is_empty() {
6766 return;
6767 }
6768 let n = ed.stops.len() as i32;
6769 let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
6770 ed.selected_stop_index = next;
6771 }
6772
6773 pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
6774 let Some(ed) = self.state.worker_route_editor.as_mut() else {
6775 return;
6776 };
6777 if delta < 0 {
6778 ed.move_selected_up();
6779 } else if delta > 0 {
6780 ed.move_selected_down();
6781 }
6782 }
6783
6784 pub fn worker_route_editor_delete_selected(&mut self) {
6785 let removed = self
6786 .state
6787 .worker_route_editor
6788 .as_mut()
6789 .is_some_and(|ed| {
6790 let before = ed.stop_count();
6791 ed.remove_selected_stop();
6792 ed.stop_count() < before
6793 });
6794 if removed {
6795 self.state.push_log("Route: removed selected stop");
6796 }
6797 }
6798
6799 pub fn worker_route_editor_clear_stops(&mut self) {
6802 let Some(ed) = self.state.worker_route_editor.as_mut() else {
6803 return;
6804 };
6805 if ed.stops.is_empty() {
6806 self.state.push_log("Route: already empty — s saves an idle worker".to_string());
6807 return;
6808 }
6809 ed.stops.clear();
6810 ed.selected_stop_index = 0;
6811 self.state
6812 .push_log("Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string());
6813 }
6814
6815 pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
6816 if self.state.pending_worker_job_ack.is_some() {
6817 anyhow::bail!("route save still pending — wait for server ack");
6818 }
6819 let Some(ed) = self.state.worker_route_editor.clone() else {
6820 anyhow::bail!("route editor not open");
6821 };
6822 let (job_yaml, idle) = if ed.stops.is_empty() {
6825 (ed.build_idle_job_yaml(), true)
6826 } else {
6827 (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
6828 };
6829 let worker_id = ed.worker_instance_id.clone();
6830 let route_view = if idle {
6831 None
6832 } else {
6833 Some(ed.to_route_view())
6834 };
6835 let mode = if idle {
6836 flatland_protocol::WorkerModeView::Idle
6837 } else {
6838 flatland_protocol::WorkerModeView::JobLoop
6839 };
6840 let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
6841 .state
6842 .hired_workers
6843 .iter()
6844 .find(|w| w.instance_id == worker_id)
6845 .map(|w| {
6846 (
6847 w.route.clone(),
6848 w.mode,
6849 w.step_label.clone(),
6850 w.last_error.clone(),
6851 )
6852 })
6853 .unwrap_or((
6854 None,
6855 flatland_protocol::WorkerModeView::Idle,
6856 String::new(),
6857 None,
6858 ));
6859 self.seq += 1;
6860 let seq = self.seq;
6861 self.session
6862 .submit_intent(Intent::SetWorkerJob {
6863 entity_id: self.state.entity_id,
6864 worker_instance_id: worker_id.clone(),
6865 job_yaml,
6866 seq,
6867 })
6868 .await?;
6869 self.state.intents_sent += 1;
6870 if let Some(w) = self
6871 .state
6872 .hired_workers
6873 .iter_mut()
6874 .find(|w| w.instance_id == worker_id)
6875 {
6876 w.route = route_view;
6877 w.mode = mode;
6878 w.last_error = None;
6879 if idle {
6880 w.step_label.clear();
6881 }
6882 }
6883 self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
6884 seq,
6885 worker_instance_id: worker_id,
6886 worker_label: ed.worker_label.clone(),
6887 idle,
6888 stop_count: ed.stops.len(),
6889 prev_route,
6890 prev_mode,
6891 prev_step_label,
6892 prev_last_error,
6893 });
6894 self.state.push_log(format!(
6895 "Route: saving for {}… (waiting for server)",
6896 ed.worker_label
6897 ));
6898 Ok(())
6900 }
6901 pub fn quest_menu_move(&mut self, delta: i32) {
6902 let n = self.state.active_quest_entries().len();
6903 if n == 0 {
6904 return;
6905 }
6906 let idx = self.state.quest_menu_index as i32;
6907 self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
6908 }
6909
6910 pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
6911 let Some(offer) = self.state.pending_quest_offer.clone() else {
6912 anyhow::bail!("no quest offer");
6913 };
6914 self.seq += 1;
6915 let seq = self.seq;
6916 self.session
6917 .submit_intent(Intent::AcceptQuest {
6918 entity_id: self.state.entity_id,
6919 quest_id: offer.quest_id,
6920 seq,
6921 })
6922 .await?;
6923 self.state.intents_sent += 1;
6924 Ok(())
6925 }
6926
6927 pub fn quest_offer_decline(&mut self) {
6928 self.state.show_quest_offer = false;
6929 self.state.pending_quest_offer = None;
6930 if !self.state.show_npc_chat
6931 && !self.state.show_shop_menu
6932 && self.state.npc_verb_target.is_some()
6933 {
6934 self.state.show_npc_verb_menu = true;
6935 }
6936 }
6937
6938 pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
6939 if !self.state.show_quest_menu {
6940 return Ok(());
6941 }
6942 let active: Vec<_> = self
6943 .state
6944 .active_quest_entries()
6945 .into_iter()
6946 .cloned()
6947 .collect();
6948 let Some(entry) = active.get(self.state.quest_menu_index) else {
6949 return Ok(());
6950 };
6951 if self.state.quest_withdraw_confirm {
6952 if !entry.can_withdraw {
6953 anyhow::bail!("quest cannot be withdrawn");
6954 }
6955 self.seq += 1;
6956 let seq = self.seq;
6957 self.session
6958 .submit_intent(Intent::WithdrawQuest {
6959 entity_id: self.state.entity_id,
6960 quest_id: entry.quest_id.clone(),
6961 seq,
6962 })
6963 .await?;
6964 self.state.intents_sent += 1;
6965 self.state.quest_withdraw_confirm = false;
6966 return Ok(());
6967 }
6968 self.seq += 1;
6969 let seq = self.seq;
6970 self.session
6971 .submit_intent(Intent::TrackQuest {
6972 entity_id: self.state.entity_id,
6973 quest_id: entry.quest_id.clone(),
6974 seq,
6975 })
6976 .await?;
6977 self.state.intents_sent += 1;
6978 Ok(())
6979 }
6980
6981 pub fn quest_request_withdraw(&mut self) {
6982 if self.state.show_quest_menu {
6983 self.state.quest_withdraw_confirm = true;
6984 }
6985 }
6986
6987 pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
6988 if !self.state.is_alive() {
6989 anyhow::bail!("you are dead");
6990 }
6991 let Some(catalog) = self.state.shop_catalog.clone() else {
6992 anyhow::bail!("no shop open");
6993 };
6994 self.seq += 1;
6995 let seq = self.seq;
6996 match self.state.shop_tab {
6997 ShopTab::Buy => {
6998 let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
6999 anyhow::bail!("nothing selected");
7000 };
7001 if offer.already_owned {
7002 anyhow::bail!("already owned");
7003 }
7004 self.session
7005 .submit_intent(Intent::ShopBuy {
7006 entity_id: self.state.entity_id,
7007 npc_id: catalog.npc_id.clone(),
7008 offer_id: offer.offer_id.clone(),
7009 quantity: self.state.shop_quantity,
7010 seq,
7011 })
7012 .await?;
7013 }
7014 ShopTab::Sell => {
7015 let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
7016 anyhow::bail!("nothing to sell");
7017 };
7018 if line.quantity == 0 {
7019 anyhow::bail!("you have no {}", line.label);
7020 }
7021 let quantity = self.state.shop_quantity.min(line.quantity).max(1);
7022 self.session
7023 .submit_intent(Intent::ShopSell {
7024 entity_id: self.state.entity_id,
7025 npc_id: catalog.npc_id.clone(),
7026 template_id: line.template_id.clone(),
7027 quantity,
7028 seq,
7029 })
7030 .await?;
7031 }
7032 }
7033 self.state.intents_sent += 1;
7034 Ok(())
7035 }
7036
7037 pub fn craft_menu_move(&mut self, delta: i32) {
7038 let n = self.state.blueprints.len();
7039 if n == 0 {
7040 return;
7041 }
7042 let idx = self.state.craft_menu_index as i32;
7043 let next = (idx + delta).rem_euclid(n as i32);
7044 self.state.craft_menu_index = next as usize;
7045 self.state.clamp_craft_batch_quantity();
7046 }
7047
7048 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
7049 self.state.craft_batch_adjust_quantity(delta);
7050 }
7051
7052 pub fn craft_batch_set_max(&mut self) {
7053 self.state.craft_batch_set_max();
7054 }
7055
7056 pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
7057 let Some(blueprint) = self
7058 .state
7059 .blueprints
7060 .get(self.state.craft_menu_index)
7061 .cloned()
7062 else {
7063 anyhow::bail!("no blueprints known");
7064 };
7065 if !self.state.can_craft_blueprint(&blueprint) {
7066 let hint = self
7067 .state
7068 .craft_missing_hint(&blueprint)
7069 .unwrap_or_else(|| "missing materials".into());
7070 anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
7071 }
7072 let count = self.state.craft_batch_quantity;
7073 let max = self.state.max_craft_batches(&blueprint);
7074 if max == 0 {
7075 anyhow::bail!("cannot craft {}", blueprint.label);
7076 }
7077 let batches = count.min(max);
7078 self.craft(&blueprint.id, Some(batches)).await?;
7079 self.state.show_craft_menu = false;
7080 Ok(())
7081 }
7082
7083 pub async fn move_by(
7084 &mut self,
7085 forward: f32,
7086 strafe: f32,
7087 vertical: f32,
7088 sprint: bool,
7089 ) -> anyhow::Result<()> {
7090 if !self.state.is_alive() {
7091 anyhow::bail!("you are dead");
7092 }
7093 if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
7094 self.last_move_forward = forward;
7095 self.last_move_strafe = strafe;
7096 }
7097 self.seq += 1;
7098 self.session
7099 .submit_intent(Intent::Move {
7100 entity_id: self.state.entity_id,
7101 forward,
7102 strafe,
7103 vertical,
7104 sprint,
7105 seq: self.seq,
7106 })
7107 .await?;
7108 self.state.intents_sent += 1;
7109 Ok(())
7110 }
7111
7112 pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
7113 if !self.state.connected {
7114 crate::harvest_trace!("harvest_nearest rejected: not connected");
7115 anyhow::bail!("not connected");
7116 }
7117 if !self.state.is_alive() {
7118 crate::harvest_trace!("harvest_nearest rejected: player dead");
7119 anyhow::bail!("you are dead");
7120 }
7121 if self.state.harvest_in_progress {
7122 if self.state.harvest_state_stale() {
7123 self.state.clear_harvest_state();
7124 } else {
7125 anyhow::bail!("already harvesting");
7126 }
7127 }
7128 let (px, py) = self
7129 .state
7130 .player
7131 .as_ref()
7132 .map(|p| (p.transform.position.x, p.transform.position.y))
7133 .unwrap_or((0.0, 0.0));
7134
7135 let available = self
7136 .state
7137 .resource_nodes
7138 .iter()
7139 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
7140 .count();
7141 let node_id = self
7142 .state
7143 .resource_nodes
7144 .iter()
7145 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
7146 .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
7147 .min_by(|a, b| {
7148 let da = distance(px, py, a.x, a.y);
7149 let db = distance(px, py, b.x, b.y);
7150 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
7151 })
7152 .map(|n| n.id.clone());
7153
7154 let Some(node_id) = node_id else {
7155 let has_loot = self
7156 .state
7157 .ground_drops
7158 .iter()
7159 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
7160 if has_loot {
7161 return self.pickup_nearest().await;
7162 }
7163 anyhow::bail!(
7164 "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
7165 );
7166 };
7167
7168 self.seq += 1;
7169 let seq = self.seq;
7170 crate::harvest_trace!(
7171 entity_id = self.state.entity_id,
7172 node_id = %node_id,
7173 seq,
7174 px,
7175 py,
7176 available_nodes = available,
7177 "submitting harvest intent"
7178 );
7179 self.session
7180 .submit_intent(Intent::Harvest {
7181 entity_id: self.state.entity_id,
7182 node_id,
7183 seq,
7184 })
7185 .await?;
7186 self.state.intents_sent += 1;
7187 self.state.harvest_in_progress = true;
7188 self.state.harvest_started_at = Some(Instant::now());
7189 self.state.push_log("Harvesting…");
7190 crate::harvest_trace!(
7191 entity_id = self.state.entity_id,
7192 seq,
7193 "harvest intent queued to session"
7194 );
7195 Ok(())
7196 }
7197
7198 pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
7199 if !self.state.is_alive() {
7200 anyhow::bail!("you are dead");
7201 }
7202 let blueprint_id = self
7203 .state
7204 .blueprints
7205 .iter()
7206 .find(|bp| self.state.can_craft_blueprint(bp))
7207 .map(|bp| bp.id.clone())
7208 .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
7209 self.craft(&blueprint_id, None).await
7210 }
7211
7212 pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
7213 if !self.state.is_alive() {
7214 anyhow::bail!("you are dead");
7215 }
7216 self.seq += 1;
7217 self.session
7218 .submit_intent(Intent::Craft {
7219 entity_id: self.state.entity_id,
7220 blueprint_id: blueprint_id.to_string(),
7221 count,
7222 seq: self.seq,
7223 })
7224 .await?;
7225 self.state.intents_sent += 1;
7226 let (label, batches) = self
7227 .state
7228 .blueprints
7229 .iter()
7230 .find(|b| b.id == blueprint_id)
7231 .map(|b| {
7232 let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
7233 (b.label.as_str(), n)
7234 })
7235 .unwrap_or((blueprint_id, count.unwrap_or(1)));
7236 self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
7237 Ok(())
7238 }
7239
7240 pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
7241 if !self.state.is_alive() {
7242 anyhow::bail!("you are dead");
7243 }
7244 let target_id = match self.state.nearest_interact_target() {
7245 Some(id) => id,
7246 None => {
7247 anyhow::bail!("nothing to interact with nearby");
7248 }
7249 };
7250 if self.state.npcs.iter().any(|n| n.id == target_id) {
7251 self.state.show_npc_verb_menu = true;
7252 self.state.npc_verb_target = Some(target_id);
7253 self.state.npc_verb_index = 0;
7254 return Ok(());
7255 }
7256 self.seq += 1;
7257 self.session
7258 .submit_intent(Intent::Interact {
7259 entity_id: self.state.entity_id,
7260 target_id: target_id.clone(),
7261 seq: self.seq,
7262 })
7263 .await?;
7264 self.state.intents_sent += 1;
7265 Ok(())
7266 }
7267
7268 pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
7270 if !self.state.is_alive() {
7271 anyhow::bail!("you are dead");
7272 }
7273 if self.state.nearest_interact_target().is_some() {
7274 return self.interact_nearest().await;
7275 }
7276 if let Some((label, dist)) = self.state.nearest_quest_board() {
7279 if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
7280 anyhow::bail!(
7281 "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
7282 );
7283 }
7284 }
7285 let (px, py) = self.state.player_position();
7286 let has_loot = self
7287 .state
7288 .ground_drops
7289 .iter()
7290 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
7291 if has_loot {
7292 return self.pickup_nearest().await;
7293 }
7294 if self
7295 .state
7296 .placed_containers
7297 .iter()
7298 .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
7299 {
7300 return self.pickup_nearest_container().await;
7301 }
7302 match self.harvest_nearest().await {
7303 Ok(()) => Ok(()),
7304 Err(err) => {
7305 let msg = err.to_string();
7306 if msg.contains("no harvestable")
7307 || msg.contains("press p")
7308 || msg.contains("press f")
7309 {
7310 anyhow::bail!(
7311 "nothing to use nearby — stand by an NPC/door, loot (*), chest, or resource"
7312 );
7313 }
7314 Err(err)
7315 }
7316 }
7317 }
7318
7319 pub async fn cast_hotbar_ability(&mut self, ability_id: &str) -> anyhow::Result<()> {
7321 if !self.state.is_alive() {
7322 anyhow::bail!("you are dead");
7323 }
7324 let target = if ability_id == "heal_touch" {
7325 Some(
7326 self.state
7327 .target_for_slot(2)
7328 .unwrap_or(self.state.entity_id),
7329 )
7330 } else {
7331 self.state
7332 .target_for_slot(1)
7333 .or_else(|| self.state.target_for_slot(2))
7334 };
7335 let Some(target_id) = target else {
7336 anyhow::bail!("no target — Tab to select, then press the hotbar key");
7337 };
7338 self.cast_ability(ability_id, Some(target_id)).await
7339 }
7340
7341 pub fn npc_verb_options(&self) -> Vec<&'static str> {
7342 self.state.npc_verb_options()
7343 }
7344
7345 pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
7346 let Some(npc_id) = self.state.npc_verb_target.clone() else {
7347 return Ok(());
7348 };
7349 let options = self.npc_verb_options();
7350 let choice = options
7351 .get(self.state.npc_verb_index)
7352 .copied()
7353 .unwrap_or("Talk");
7354 self.seq += 1;
7355 match choice {
7356 "Trade" => {
7357 self.session
7358 .submit_intent(Intent::Interact {
7359 entity_id: self.state.entity_id,
7360 target_id: npc_id,
7361 seq: self.seq,
7362 })
7363 .await?;
7364 }
7365 _ => {
7366 self.session
7367 .submit_intent(Intent::NpcTalkOpen {
7368 entity_id: self.state.entity_id,
7369 npc_id,
7370 seq: self.seq,
7371 })
7372 .await?;
7373 }
7374 }
7375 self.state.intents_sent += 1;
7376 Ok(())
7377 }
7378
7379 pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
7380 let Some(chat) = self.state.npc_chat.clone() else {
7381 return Ok(());
7382 };
7383 let message = chat.input.trim().to_string();
7384 if message.is_empty() || chat.pending {
7385 return Ok(());
7386 }
7387 if let Some(c) = self.state.npc_chat.as_mut() {
7388 c.lines.push(format!("You: {message}"));
7389 c.input.clear();
7390 c.pending = true;
7391 }
7392 self.seq += 1;
7393 self.session
7394 .submit_intent(Intent::NpcTalkSay {
7395 entity_id: self.state.entity_id,
7396 npc_id: chat.npc_id,
7397 message,
7398 seq: self.seq,
7399 })
7400 .await?;
7401 self.state.intents_sent += 1;
7402 Ok(())
7403 }
7404
7405 pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
7406 let return_to_verbs = self.state.npc_verb_target.is_some();
7407 let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
7408 self.state.show_npc_chat = false;
7409 if return_to_verbs {
7410 self.state.show_npc_verb_menu = true;
7411 }
7412 return Ok(());
7413 };
7414 self.seq += 1;
7415 self.session
7416 .submit_intent(Intent::NpcTalkClose {
7417 entity_id: self.state.entity_id,
7418 npc_id,
7419 seq: self.seq,
7420 })
7421 .await?;
7422 self.state.intents_sent += 1;
7423 self.state.show_npc_chat = false;
7424 self.state.npc_chat = None;
7425 if return_to_verbs {
7426 self.state.show_npc_verb_menu = true;
7427 }
7428 Ok(())
7429 }
7430
7431 pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
7433 if self.state.show_quest_offer
7434 && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
7435 {
7436 self.quest_offer_decline();
7437 return Ok(());
7438 }
7439 if self.state.show_npc_chat {
7440 return self.npc_talk_close().await;
7441 }
7442 if self.state.show_shop_menu {
7443 self.back_from_shop_menu();
7444 return Ok(());
7445 }
7446 if self.state.show_npc_verb_menu {
7447 self.state.show_npc_verb_menu = false;
7448 self.state.npc_verb_target = None;
7449 }
7450 Ok(())
7451 }
7452
7453 pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
7454 self.seq += 1;
7455 self.session
7456 .submit_intent(Intent::TestDamage {
7457 entity_id: self.state.entity_id,
7458 amount,
7459 seq: self.seq,
7460 })
7461 .await?;
7462 self.state.intents_sent += 1;
7463 Ok(())
7464 }
7465
7466 pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
7467 self.cycle_combat_target_slot(1, reverse).await
7468 }
7469
7470 pub async fn cycle_combat_target_slot(
7471 &mut self,
7472 slot_index: u8,
7473 reverse: bool,
7474 ) -> anyhow::Result<()> {
7475 if !self.state.is_alive() {
7476 anyhow::bail!("you are dead");
7477 }
7478 let candidates = self.state.candidates_for_slot(slot_index);
7479 if candidates.is_empty() {
7480 anyhow::bail!("no targets nearby");
7481 }
7482 let current = self.state.target_for_slot(slot_index);
7483 let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
7484 let next_idx = match idx {
7485 None => 0,
7486 Some(i) if reverse => {
7487 if i == 0 {
7488 candidates.len() - 1
7489 } else {
7490 i - 1
7491 }
7492 }
7493 Some(i) => (i + 1) % candidates.len(),
7494 };
7495 if idx == Some(next_idx) && candidates.len() == 1 {
7496 self.clear_combat_target_slot(slot_index).await?;
7497 return Ok(());
7498 }
7499 let (target_id, label) = candidates[next_idx].clone();
7500 self.set_combat_target_slot(slot_index, target_id, &label)
7501 .await
7502 }
7503
7504 pub async fn set_combat_target_slot(
7505 &mut self,
7506 slot_index: u8,
7507 target_id: EntityId,
7508 label: &str,
7509 ) -> anyhow::Result<()> {
7510 if !self.state.is_alive() {
7511 anyhow::bail!("you are dead");
7512 }
7513 self.seq += 1;
7514 self.session
7515 .submit_intent(Intent::SetTargetSlot {
7516 entity_id: self.state.entity_id,
7517 slot_index,
7518 target_id,
7519 seq: self.seq,
7520 })
7521 .await?;
7522 self.state.intents_sent += 1;
7523 if slot_index == 1 {
7524 self.state.combat_target = Some(target_id);
7525 self.state.combat_target_label = Some(label.to_string());
7526 }
7527 self.state
7528 .push_log(format!("Slot {slot_index} target: {label}"));
7529 Ok(())
7530 }
7531
7532 pub async fn set_combat_target(
7533 &mut self,
7534 target_id: EntityId,
7535 label: &str,
7536 ) -> anyhow::Result<()> {
7537 self.set_combat_target_slot(1, target_id, label).await
7538 }
7539
7540 pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
7541 if slot_index == 1 && self.state.combat_target.is_none() {
7542 return Ok(());
7543 }
7544 self.seq += 1;
7545 self.session
7546 .submit_intent(Intent::ClearTargetSlot {
7547 entity_id: self.state.entity_id,
7548 slot_index,
7549 seq: self.seq,
7550 })
7551 .await?;
7552 if slot_index == 1 {
7553 self.state.combat_target = None;
7554 self.state.combat_target_label = None;
7555 }
7556 self.state.intents_sent += 1;
7557 self.state
7558 .push_log(format!("Slot {slot_index} target cleared"));
7559 Ok(())
7560 }
7561
7562 pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
7563 self.clear_combat_target_slot(1).await
7564 }
7565
7566 pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
7567 if !self.state.is_alive() {
7568 anyhow::bail!("you are dead");
7569 }
7570 self.seq += 1;
7571 self.session
7572 .submit_intent(Intent::AdvanceRotation {
7573 entity_id: self.state.entity_id,
7574 slot_index,
7575 seq: self.seq,
7576 })
7577 .await?;
7578 self.state.intents_sent += 1;
7579 Ok(())
7580 }
7581
7582 pub async fn assign_slot_preset(
7583 &mut self,
7584 slot_index: u8,
7585 preset_id: &str,
7586 ) -> anyhow::Result<()> {
7587 if !self.state.is_alive() {
7588 anyhow::bail!("you are dead");
7589 }
7590 self.seq += 1;
7591 self.session
7592 .submit_intent(Intent::AssignSlotPreset {
7593 entity_id: self.state.entity_id,
7594 slot_index,
7595 preset_id: preset_id.to_string(),
7596 seq: self.seq,
7597 })
7598 .await?;
7599 self.state.intents_sent += 1;
7600 if let Some(slot) = self
7601 .state
7602 .combat_slots
7603 .iter_mut()
7604 .find(|s| s.slot_index == slot_index)
7605 {
7606 slot.preset_id = Some(preset_id.to_string());
7607 if let Some(preset) = self
7608 .state
7609 .rotation_presets
7610 .iter()
7611 .find(|p| p.id == preset_id)
7612 {
7613 slot.preset_label = Some(preset.label.clone());
7614 slot.rotation = preset.abilities.clone();
7615 slot.rotation_index = 0;
7616 }
7617 }
7618 self.state
7619 .push_log(format!("T{slot_index} loadout → {preset_id}"));
7620 Ok(())
7621 }
7622
7623 pub async fn cast_ability(
7624 &mut self,
7625 ability_id: &str,
7626 target_id: Option<EntityId>,
7627 ) -> anyhow::Result<()> {
7628 if !self.state.is_alive() {
7629 anyhow::bail!("you are dead");
7630 }
7631 let target_id = target_id
7632 .or_else(|| self.state.target_for_slot(2))
7633 .or_else(|| self.state.target_for_slot(1))
7634 .unwrap_or(self.state.entity_id);
7635 self.seq += 1;
7636 self.session
7637 .submit_intent(Intent::Cast {
7638 entity_id: self.state.entity_id,
7639 ability_id: ability_id.to_string(),
7640 target_id,
7641 seq: self.seq,
7642 })
7643 .await?;
7644 self.state.intents_sent += 1;
7645 self.state
7646 .push_log(format!("Cast {ability_id} → {target_id}"));
7647 Ok(())
7648 }
7649
7650 pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
7651 self.seq += 1;
7652 self.session
7653 .submit_intent(Intent::UpsertRotationPreset {
7654 entity_id: self.state.entity_id,
7655 preset: preset.clone(),
7656 seq: self.seq,
7657 })
7658 .await?;
7659 self.state.intents_sent += 1;
7660 if let Some(existing) = self
7661 .state
7662 .rotation_presets
7663 .iter_mut()
7664 .find(|p| p.id == preset.id)
7665 {
7666 *existing = preset.clone();
7667 } else {
7668 self.state.rotation_presets.push(preset.clone());
7669 }
7670 for slot in &mut self.state.combat_slots {
7671 if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
7672 slot.preset_label = Some(preset.label.clone());
7673 slot.rotation = preset.abilities.clone();
7674 }
7675 }
7676 self.state
7677 .push_log(format!("Saved rotation: {}", preset.label));
7678 Ok(())
7679 }
7680
7681 pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
7682 self.seq += 1;
7683 self.session
7684 .submit_intent(Intent::DeleteRotationPreset {
7685 entity_id: self.state.entity_id,
7686 preset_id: preset_id.to_string(),
7687 seq: self.seq,
7688 })
7689 .await?;
7690 self.state.intents_sent += 1;
7691 self.state.rotation_presets.retain(|p| p.id != preset_id);
7692 for slot in &mut self.state.combat_slots {
7693 if slot.preset_id.as_deref() == Some(preset_id) {
7694 slot.preset_id = None;
7695 slot.preset_label = None;
7696 slot.rotation.clear();
7697 slot.rotation_index = 0;
7698 }
7699 }
7700 self.state
7701 .push_log(format!("Deleted rotation: {preset_id}"));
7702 Ok(())
7703 }
7704
7705 pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
7706 if !self.state.is_alive() {
7707 anyhow::bail!("you are dead");
7708 }
7709 let enabled = !self
7710 .state
7711 .combat_slots
7712 .iter()
7713 .find(|s| s.slot_index == slot_index)
7714 .map(|s| s.auto_enabled)
7715 .unwrap_or(false);
7716 self.seq += 1;
7717 self.session
7718 .submit_intent(Intent::SetAutoAttack {
7719 entity_id: self.state.entity_id,
7720 slot_index,
7721 enabled,
7722 seq: self.seq,
7723 })
7724 .await?;
7725 if slot_index == 1 {
7726 self.state.auto_attack = enabled;
7727 }
7728 self.state.intents_sent += 1;
7729 self.state.push_log(format!(
7730 "T{slot_index} auto {}",
7731 if enabled { "ON" } else { "OFF" }
7732 ));
7733 Ok(())
7734 }
7735
7736 pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
7737 if !self.state.connected {
7738 anyhow::bail!("not connected");
7739 }
7740 if !self.state.is_alive() {
7741 anyhow::bail!("you are dead");
7742 }
7743 let (px, py) = self.state.player_position();
7744 if self
7745 .state
7746 .ground_drops
7747 .iter()
7748 .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
7749 {
7750 anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
7751 }
7752 self.seq += 1;
7753 self.session
7754 .submit_intent(Intent::Pickup {
7755 entity_id: self.state.entity_id,
7756 drop_id: None,
7757 seq: self.seq,
7758 })
7759 .await?;
7760 self.state.intents_sent += 1;
7761 Ok(())
7762 }
7763
7764 pub async fn toggle_auto_attack(&mut self) -> anyhow::Result<()> {
7765 self.toggle_auto_attack_slot(1).await
7766 }
7767
7768 pub async fn dodge(&mut self) -> anyhow::Result<()> {
7769 if !self.state.is_alive() {
7770 anyhow::bail!("you are dead");
7771 }
7772 self.seq += 1;
7773 self.session
7774 .submit_intent(Intent::Dodge {
7775 entity_id: self.state.entity_id,
7776 seq: self.seq,
7777 })
7778 .await?;
7779 self.state.intents_sent += 1;
7780 self.state.push_log("Dodge!");
7781 Ok(())
7782 }
7783
7784 pub async fn lunge(&mut self) -> anyhow::Result<()> {
7785 if !self.state.is_alive() {
7786 anyhow::bail!("you are dead");
7787 }
7788 let (forward, strafe) = self.last_move_axes();
7789 self.seq += 1;
7790 self.session
7791 .submit_intent(Intent::Lunge {
7792 entity_id: self.state.entity_id,
7793 forward,
7794 strafe,
7795 seq: self.seq,
7796 })
7797 .await?;
7798 self.state.intents_sent += 1;
7799 self.state.push_log("Lunge!");
7800 Ok(())
7801 }
7802
7803 pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
7804 if !self.state.is_alive() {
7805 anyhow::bail!("you are dead");
7806 }
7807 self.seq += 1;
7808 self.session
7809 .submit_intent(Intent::DirectionalJump {
7810 entity_id: self.state.entity_id,
7811 forward,
7812 strafe,
7813 seq: self.seq,
7814 })
7815 .await?;
7816 self.state.intents_sent += 1;
7817 self.state.push_log("Jump!");
7818 Ok(())
7819 }
7820
7821 pub fn last_move_axes(&self) -> (f32, f32) {
7823 (self.last_move_forward, self.last_move_strafe)
7824 }
7825
7826 pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
7827 if !self.state.is_alive() {
7828 anyhow::bail!("you are dead");
7829 }
7830 self.seq += 1;
7831 self.session
7832 .submit_intent(Intent::Block {
7833 entity_id: self.state.entity_id,
7834 enabled,
7835 seq: self.seq,
7836 })
7837 .await?;
7838 self.state.intents_sent += 1;
7839 if enabled {
7840 self.state.push_log("Blocking");
7841 }
7842 Ok(())
7843 }
7844
7845 pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
7846 if !self.state.is_alive() {
7847 anyhow::bail!("you are dead");
7848 }
7849 self.seq += 1;
7850 self.session
7851 .submit_intent(Intent::EquipMainhand {
7852 entity_id: self.state.entity_id,
7853 template_id,
7854 seq: self.seq,
7855 })
7856 .await?;
7857 self.state.intents_sent += 1;
7858 Ok(())
7859 }
7860
7861 pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
7863 let idx = self.state.equip_menu_index;
7864 let slots = equip_paperdoll_rows(&self.state);
7865 let Some(row) = slots.get(idx) else {
7866 return Ok(());
7867 };
7868 match row {
7869 EquipPaperdollRow::Body { slot, filled } => {
7870 if *filled {
7871 self.equip_worn(*slot, None).await
7872 } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
7873 self.equip_worn(*slot, Some(inst)).await
7874 } else {
7875 self.state.push_log(format!("No item for {}", body_slot_label(*slot)));
7876 Ok(())
7877 }
7878 }
7879 EquipPaperdollRow::Mainhand { filled } => {
7880 if *filled {
7881 self.unequip_mainhand().await
7882 } else if let Some(tid) = first_inventory_weapon(&self.state) {
7883 self.equip_mainhand(Some(tid)).await
7884 } else {
7885 self.state.push_log("No weapon in inventory".to_string());
7886 Ok(())
7887 }
7888 }
7889 EquipPaperdollRow::Offhand { filled, locked } => {
7890 if *locked {
7891 self.state
7892 .push_log("Offhand locked — two-handed weapon equipped".to_string());
7893 Ok(())
7894 } else if *filled {
7895 self.unequip_offhand().await
7896 } else if let Some(tid) = first_inventory_offhand(&self.state) {
7897 self.equip_offhand(Some(tid)).await
7898 } else {
7899 self.state
7900 .push_log("No offhand item in inventory".to_string());
7901 Ok(())
7902 }
7903 }
7904 }
7905 }
7906
7907 pub async fn say(
7908 &mut self,
7909 channel: flatland_protocol::ChatChannel,
7910 text: &str,
7911 ) -> anyhow::Result<()> {
7912 self.seq += 1;
7913 self.session
7914 .submit_intent(Intent::Say {
7915 entity_id: self.state.entity_id,
7916 channel,
7917 text: text.to_string(),
7918 seq: self.seq,
7919 })
7920 .await?;
7921 self.state.intents_sent += 1;
7922 Ok(())
7923 }
7924
7925 pub async fn stop(&mut self) -> anyhow::Result<()> {
7926 self.seq += 1;
7927 self.session
7928 .submit_intent(Intent::Stop {
7929 entity_id: self.state.entity_id,
7930 seq: self.seq,
7931 })
7932 .await?;
7933 self.state.intents_sent += 1;
7934 Ok(())
7935 }
7936
7937 pub fn disconnect(&self) {
7938 self.session.disconnect();
7939 }
7940}
7941
7942fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
7943 let dx = ax - bx;
7944 let dy = ay - by;
7945 (dx * dx + dy * dy).sqrt()
7946}
7947
7948#[cfg(test)]
7949mod tests {
7950 use std::collections::BTreeMap;
7951
7952 use super::*;
7953 use flatland_protocol::{
7954 BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
7955 };
7956
7957 fn sample_state() -> GameState {
7958 let mut state = GameState {
7959 session_id: 1,
7960 entity_id: 1,
7961 character_id: None,
7962 tick: 0,
7963 chunk_rev: 0,
7964 content_rev: 0,
7965 publish_rev: 0,
7966 entities: vec![EntityState {
7967 id: 1,
7968 label: "You".into(),
7969 transform: Transform {
7970 position: WorldCoord::surface(128.0, 128.0),
7971 yaw: 0.0,
7972 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
7973 },
7974 vitals: None,
7975 attributes: None,
7976 skills: None,
7977 inside_building: None,
7978 tile_id: None,
7979 presentation_state: None,
7980 sprite_mode: None,
7981 progression_xp: None,
7982 }],
7983 player: None,
7984 resource_nodes: vec![ResourceNodeView {
7985 id: "oak-1".into(),
7986 label: "Oak".into(),
7987 x: 130.0,
7988 y: 128.0,
7989 z: 0.0,
7990 item_template: "oak_log".into(),
7991 state: ResourceNodeState::Available,
7992 blocking: true,
7993 blocking_radius_m: 0.8,
7994 tile_id: None,
7995 sprite_mode: None,
7996 presentation_state: None,
7997 }],
7998 ground_drops: vec![],
7999 placed_containers: vec![],
8000 buildings: vec![BuildingView {
8001 id: "broker-hut".into(),
8002 label: "Broker".into(),
8003 x: 148.0,
8004 y: 118.0,
8005 width_m: 8.0,
8006 depth_m: 6.0,
8007 interior_blueprint: Some("broker_hut".into()),
8008 tags: vec![],
8009 }],
8010 doors: vec![flatland_protocol::DoorView {
8011 id: "door-1".into(),
8012 building_id: "broker-hut".into(),
8013 x: 148.0,
8014 y: 118.0,
8015 open: false,
8016 portal: Some("front".into()),
8017 }],
8018 interior_map: None,
8019 npcs: vec![],
8020 blueprints: vec![],
8021 world_width_m: 256.0,
8022 world_height_m: 256.0,
8023 terrain_zones: Vec::new(),
8024 z_platforms: Vec::new(),
8025 z_transitions: Vec::new(),
8026 world_clock: flatland_protocol::WorldClock::default(),
8027 inventory: std::collections::HashMap::new(),
8028 inventory_hints: std::collections::HashMap::new(),
8029 logs: VecDeque::new(),
8030 intents_sent: 0,
8031 ticks_received: 0,
8032 connected: true,
8033 disconnect_reason: None,
8034 show_stats: false,
8035 show_equip_menu: false,
8036 equip_menu_index: 0,
8037 show_craft_menu: false,
8038 craft_menu_index: 0,
8039 craft_batch_quantity: 1,
8040 show_shop_menu: false,
8041 shop_catalog: None,
8042 shop_tab: ShopTab::default(),
8043 shop_menu_index: 0,
8044 shop_quantity: 1,
8045 shop_trade_log: VecDeque::new(),
8046 show_npc_verb_menu: false,
8047 npc_verb_target: None,
8048 npc_verb_index: 0,
8049 show_npc_chat: false,
8050 npc_chat: None,
8051 show_inventory_menu: false,
8052 inventory_menu_index: 0,
8053 show_move_picker: false,
8054 show_rename_prompt: false,
8055 show_worker_rename: false,
8056 rename_buffer: String::new(),
8057 move_picker_index: 0,
8058 move_picker: None,
8059 show_destroy_picker: false,
8060 destroy_confirm_pending: false,
8061 destroy_picker: None,
8062 combat_target: None,
8063 combat_target_label: None,
8064 in_combat: false,
8065 auto_attack: true,
8066 combat_has_los: false,
8067 attack_cd_ticks: 0,
8068 gcd_ticks: 0,
8069 weapon_ability_id: "unarmed".into(),
8070 mainhand_template_id: None,
8071 mainhand_label: None,
8072 offhand_template_id: None,
8073 offhand_label: None,
8074 mainhand_hand_slots: 1,
8075 defense: None,
8076 worn: BTreeMap::new(),
8077 carry_mass: 0.0,
8078 carry_mass_max: 0.0,
8079 encumbrance: flatland_protocol::EncumbranceState::Light,
8080 inventory_stacks: Vec::new(),
8081 keychain_stacks: Vec::new(),
8082 combat_target_detail: None,
8083 cast_progress: None,
8084 ability_cooldowns: Vec::new(),
8085 blocking_active: false,
8086 max_target_slots: 1,
8087 combat_slots: Vec::new(),
8088 rotation_presets: Vec::new(),
8089 show_loadout_menu: false,
8090 show_keychain_menu: false,
8091 keychain_menu_index: 0,
8092 show_rotation_editor: false,
8093 loadout_menu_index: 0,
8094 rotation_editor: RotationEditorState::default(),
8095 harvest_in_progress: false,
8096 harvest_started_at: None,
8097 pending_craft_ack: None,
8098 pending_worker_job_ack: None,
8099 quest_log: Vec::new(),
8100 interactables: Vec::new(),
8101 ledger: None,
8102 career: None,
8103 character_sheet_tab: CharacterSheetTab::Character,
8104 ledger_period: LedgerPeriod::Day,
8105 show_quest_offer: false,
8106 pending_quest_offer: None,
8107 show_quest_menu: false,
8108 quest_menu_index: 0,
8109 quest_withdraw_confirm: false,
8110 hired_workers: Vec::new(),
8111 show_workers_menu: false,
8112 workers_menu_index: 0,
8113 workers_menu_compact: false,
8114 worker_step_display: BTreeMap::new(),
8115 show_worker_give_picker: false,
8116 worker_give_picker_index: 0,
8117 worker_give_picker: None,
8118 show_worker_give_target_picker: false,
8119 worker_give_target_picker_index: 0,
8120 worker_give_target_picker: None,
8121 show_worker_take_picker: false,
8122 worker_take_picker_index: 0,
8123 worker_take_picker: None,
8124 show_worker_teach_picker: false,
8125 worker_teach_picker_index: 0,
8126 worker_teach_picker: None,
8127 worker_route_editor: None,
8128 progression_curve: None,
8129 };
8130 state.player = state.entities.first().cloned();
8131 state
8132 }
8133
8134 #[test]
8135 fn probe_use_world_npc_beats_nearby_loot() {
8136 let mut state = sample_state();
8137 state.npcs.push(flatland_protocol::NpcView {
8138 id: "ada".into(),
8139 label: "Ada".into(),
8140 role: "broker".into(),
8141 x: 129.0,
8142 y: 128.0,
8143 building_id: None,
8144 entity_id: None,
8145 life_state: None,
8146 hp_pct: None,
8147 can_trade: true,
8148 tile_id: None,
8149 behavior_state: None,
8150 presentation_state: None,
8151 sprite_mode: None,
8152 });
8153 state.ground_drops.push(flatland_protocol::GroundDropView {
8154 id: "d1".into(),
8155 template_id: "lumber".into(),
8156 quantity: 1,
8157 x: 128.5,
8158 y: 128.0,
8159 z: 0.0,
8160 tile_id: None,
8161 });
8162 let probe = state.probe_use_world();
8163 let primary = probe.primary.expect("primary");
8164 assert_eq!(primary.kind, crate::UseWorldKind::Npc);
8165 assert_eq!(primary.id, "ada");
8166 }
8167
8168 #[test]
8169 fn probe_use_world_harvest_when_in_range() {
8170 let state = sample_state(); let probe = state.probe_use_world();
8172 assert!(
8173 probe.primary.is_none(),
8174 "oak is 2m away, out of harvest range"
8175 );
8176 assert!(probe
8177 .candidates
8178 .iter()
8179 .any(|c| c.kind == crate::UseWorldKind::Harvest));
8180
8181 let mut state = sample_state();
8182 state.resource_nodes[0].x = 129.0;
8183 let probe = state.probe_use_world();
8184 let primary = probe.primary.expect("primary");
8185 assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
8186 }
8187
8188 #[test]
8189 fn empty_entity_tick_preserves_welcome_snapshot() {
8190 let mut state = sample_state();
8191 state.inventory.insert("carrot".into(), 3);
8192 let delta = TickDelta {
8193 tick: 1,
8194 entities: vec![],
8195 resource_nodes: vec![],
8196 ground_drops: vec![],
8197 placed_containers: vec![],
8198 buildings: vec![],
8199 doors: vec![],
8200 interior_map: None,
8201 npcs: vec![],
8202 inventory: vec![],
8203 blueprints: vec![],
8204 world_clock: flatland_protocol::WorldClock::default(),
8205 combat: None,
8206 quest_log: vec![],
8207 hired_workers: Vec::new(),
8208 interactables: vec![],
8209 ledger: None,
8210 career: None,
8211 };
8212
8213 state.apply_tick_fields(&delta, 1);
8214
8215 assert_eq!(state.entities.len(), 1);
8216 assert!(state.player.is_some());
8217 assert_eq!(state.inventory.get("carrot"), Some(&3));
8218 assert_eq!(state.resource_nodes.len(), 1);
8219 }
8220
8221 #[test]
8222 fn tick_preserves_world_layers_when_delta_omits_them() {
8223 let mut state = sample_state();
8224 let delta = TickDelta {
8225 tick: 1,
8226 entities: state.entities.clone(),
8227 resource_nodes: vec![],
8228 ground_drops: vec![],
8229 placed_containers: vec![],
8230 buildings: vec![],
8231 doors: vec![],
8232 interior_map: None,
8233 npcs: vec![],
8234 inventory: vec![],
8235 blueprints: vec![],
8236 world_clock: flatland_protocol::WorldClock::default(),
8237 combat: None,
8238 quest_log: vec![],
8239 hired_workers: Vec::new(),
8240 interactables: vec![],
8241 ledger: None,
8242 career: None,
8243 };
8244
8245 state.apply_tick_fields(&delta, 1);
8246
8247 assert_eq!(state.resource_nodes.len(), 1);
8248 assert_eq!(state.buildings.len(), 1);
8249 assert_eq!(state.doors.len(), 1);
8250 }
8251
8252 #[test]
8253 fn tick_updates_resource_nodes_when_server_sends_them() {
8254 let mut state = sample_state();
8255 let delta = TickDelta {
8256 tick: 1,
8257 entities: state.entities.clone(),
8258 resource_nodes: vec![ResourceNodeView {
8259 id: "oak-1".into(),
8260 label: "Oak".into(),
8261 x: 130.0,
8262 y: 128.0,
8263 z: 0.0,
8264 item_template: "oak_log".into(),
8265 state: ResourceNodeState::Cooldown,
8266 blocking: true,
8267 blocking_radius_m: 0.8,
8268 tile_id: None,
8269 sprite_mode: None,
8270 presentation_state: None,
8271 }],
8272 buildings: vec![],
8273 doors: vec![],
8274 interior_map: None,
8275 npcs: vec![],
8276 inventory: vec![],
8277 blueprints: vec![],
8278 world_clock: flatland_protocol::WorldClock::default(),
8279 ground_drops: vec![],
8280 placed_containers: vec![],
8281 combat: None,
8282 quest_log: vec![],
8283 hired_workers: Vec::new(),
8284 interactables: vec![],
8285 ledger: None,
8286 career: None,
8287 };
8288
8289 state.apply_tick_fields(&delta, 1);
8290
8291 assert!(matches!(
8292 state.resource_nodes[0].state,
8293 ResourceNodeState::Cooldown
8294 ));
8295 }
8296
8297 #[test]
8298 fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
8299 let mut state = GameState {
8300 session_id: 1,
8301 entity_id: 1,
8302 character_id: None,
8303 tick: 0,
8304 chunk_rev: 0,
8305 content_rev: 0,
8306 publish_rev: 0,
8307 entities: vec![EntityState {
8308 id: 1,
8309 label: "You".into(),
8310 transform: Transform {
8311 position: WorldCoord::surface(4.5, 2.0),
8312 yaw: 0.0,
8313 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
8314 },
8315 vitals: None,
8316 attributes: None,
8317 skills: None,
8318 inside_building: Some("broker_hut".into()),
8319 tile_id: None,
8320 presentation_state: None,
8321 sprite_mode: None,
8322 progression_xp: None,
8323 }],
8324 player: None,
8325 resource_nodes: vec![],
8326 ground_drops: vec![],
8327 placed_containers: vec![],
8328 buildings: vec![BuildingView {
8329 id: "broker_hut".into(),
8330 label: "Broker".into(),
8331 x: 158.0,
8332 y: 124.0,
8333 width_m: 8.0,
8334 depth_m: 6.0,
8335 interior_blueprint: Some("broker_hut".into()),
8336 tags: vec![],
8337 }],
8338 doors: vec![flatland_protocol::DoorView {
8339 id: "broker_hut_exit".into(),
8340 building_id: "broker_hut".into(),
8341 x: 4.3,
8342 y: 0.9,
8343 open: true,
8344 portal: Some("front".into()),
8345 }],
8346 interior_map: None,
8347 npcs: vec![flatland_protocol::NpcView {
8348 id: "ada_broker".into(),
8349 label: "Ada".into(),
8350 x: 4.5,
8351 y: 2.0,
8352 building_id: Some("broker_hut".into()),
8353 role: "broker".into(),
8354 entity_id: None,
8355 life_state: None,
8356 hp_pct: None,
8357 can_trade: true,
8358 tile_id: None,
8359 behavior_state: None,
8360 presentation_state: None,
8361 sprite_mode: None,
8362 }],
8363 blueprints: vec![],
8364 world_width_m: 256.0,
8365 world_height_m: 256.0,
8366 terrain_zones: Vec::new(),
8367 z_platforms: Vec::new(),
8368 z_transitions: Vec::new(),
8369 world_clock: flatland_protocol::WorldClock::default(),
8370 inventory: std::collections::HashMap::new(),
8371 inventory_hints: std::collections::HashMap::new(),
8372 logs: VecDeque::new(),
8373 intents_sent: 0,
8374 ticks_received: 0,
8375 connected: true,
8376 disconnect_reason: None,
8377 show_stats: false,
8378 show_equip_menu: false,
8379 equip_menu_index: 0,
8380 show_craft_menu: false,
8381 craft_menu_index: 0,
8382 craft_batch_quantity: 1,
8383 show_shop_menu: false,
8384 shop_catalog: None,
8385 shop_tab: ShopTab::default(),
8386 shop_menu_index: 0,
8387 shop_quantity: 1,
8388 shop_trade_log: VecDeque::new(),
8389 show_npc_verb_menu: false,
8390 npc_verb_target: None,
8391 npc_verb_index: 0,
8392 show_npc_chat: false,
8393 npc_chat: None,
8394 show_inventory_menu: false,
8395 inventory_menu_index: 0,
8396 show_move_picker: false,
8397 show_rename_prompt: false,
8398 show_worker_rename: false,
8399 rename_buffer: String::new(),
8400 move_picker_index: 0,
8401 move_picker: None,
8402 show_destroy_picker: false,
8403 destroy_confirm_pending: false,
8404 destroy_picker: None,
8405 combat_target: None,
8406 combat_target_label: None,
8407 in_combat: false,
8408 auto_attack: true,
8409 combat_has_los: false,
8410 attack_cd_ticks: 0,
8411 gcd_ticks: 0,
8412 weapon_ability_id: "unarmed".into(),
8413 mainhand_template_id: None,
8414 mainhand_label: None,
8415 offhand_template_id: None,
8416 offhand_label: None,
8417 mainhand_hand_slots: 1,
8418 defense: None,
8419 worn: BTreeMap::new(),
8420 carry_mass: 0.0,
8421 carry_mass_max: 0.0,
8422 encumbrance: flatland_protocol::EncumbranceState::Light,
8423 inventory_stacks: Vec::new(),
8424 keychain_stacks: Vec::new(),
8425 combat_target_detail: None,
8426 cast_progress: None,
8427 ability_cooldowns: Vec::new(),
8428 blocking_active: false,
8429 max_target_slots: 1,
8430 combat_slots: Vec::new(),
8431 rotation_presets: Vec::new(),
8432 show_loadout_menu: false,
8433 show_keychain_menu: false,
8434 keychain_menu_index: 0,
8435 show_rotation_editor: false,
8436 loadout_menu_index: 0,
8437 rotation_editor: RotationEditorState::default(),
8438 harvest_in_progress: false,
8439 harvest_started_at: None,
8440 pending_craft_ack: None,
8441 pending_worker_job_ack: None,
8442 quest_log: Vec::new(),
8443 interactables: Vec::new(),
8444 ledger: None,
8445 career: None,
8446 character_sheet_tab: CharacterSheetTab::Character,
8447 ledger_period: LedgerPeriod::Day,
8448 show_quest_offer: false,
8449 pending_quest_offer: None,
8450 show_quest_menu: false,
8451 quest_menu_index: 0,
8452 quest_withdraw_confirm: false,
8453 hired_workers: Vec::new(),
8454 show_workers_menu: false,
8455 workers_menu_index: 0,
8456 workers_menu_compact: false,
8457 worker_step_display: BTreeMap::new(),
8458 show_worker_give_picker: false,
8459 worker_give_picker_index: 0,
8460 worker_give_picker: None,
8461 show_worker_give_target_picker: false,
8462 worker_give_target_picker_index: 0,
8463 worker_give_target_picker: None,
8464 show_worker_take_picker: false,
8465 worker_take_picker_index: 0,
8466 worker_take_picker: None,
8467 show_worker_teach_picker: false,
8468 worker_teach_picker_index: 0,
8469 worker_teach_picker: None,
8470 worker_route_editor: None,
8471 progression_curve: None,
8472 };
8473 state.player = state.entities.first().cloned();
8474 assert_eq!(
8475 state.nearest_interact_target().as_deref(),
8476 Some("ada_broker")
8477 );
8478 }
8479
8480 #[test]
8481 fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
8482 let mut state = sample_state();
8483 state.placed_containers = vec![
8486 flatland_protocol::PlacedContainerView {
8487 id: "near".into(),
8488 template_id: "wooden_chest_small".into(),
8489 display_name: "Wooden Chest".into(),
8490 x: 130.0,
8491 y: 128.0,
8492 z: 0.0,
8493 locked: true,
8494 accessible: true,
8495 owner_character_id: None,
8496 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
8497 lock_id: None,
8498 capacity_volume: None,
8499 item_instance_id: Some(uuid::Uuid::from_u128(1)),
8500 tile_id: None,
8501 worker_lodging_capacity: None,
8502 },
8503 flatland_protocol::PlacedContainerView {
8504 id: "far".into(),
8505 template_id: "wooden_chest_small".into(),
8506 display_name: "Distant Chest".into(),
8507 x: 128.0 + CONTAINER_RANGE_M + 5.0,
8508 y: 128.0,
8509 z: 0.0,
8510 locked: false,
8511 accessible: true,
8512 owner_character_id: None,
8513 contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
8514 lock_id: None,
8515 capacity_volume: None,
8516 item_instance_id: Some(uuid::Uuid::from_u128(2)),
8517 tile_id: None,
8518 worker_lodging_capacity: None,
8519 },
8520 ];
8521
8522 let nearby = state.nearby_containers();
8523 assert_eq!(
8524 nearby.len(),
8525 1,
8526 "far chest must not appear once out of range"
8527 );
8528 assert_eq!(nearby[0].view.id, "near");
8529 assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
8530 assert!(nearby[0].rows[0].is_chest_shell);
8531
8532 state.placed_containers[0].accessible = false;
8535 let nearby = state.nearby_containers();
8536 assert_eq!(nearby.len(), 1);
8537 assert_eq!(nearby[0].rows.len(), 1);
8538 assert!(nearby[0].rows[0].is_chest_shell);
8539 }
8540
8541 #[test]
8542 fn chest_pickup_destinations_offer_person_and_worn_bag() {
8543 let mut state = sample_state();
8544 let back_id = uuid::Uuid::from_u128(42);
8545 state.worn.insert(
8546 BodySlot::Back,
8547 flatland_protocol::ItemStack {
8548 template_id: "travel_backpack".into(),
8549 quantity: 1,
8550 item_instance_id: Some(back_id),
8551 props: Default::default(),
8552 contents: Vec::new(),
8553 display_name: Some("Travel Backpack".into()),
8554 category: Some("container".into()),
8555 base_mass: Some(2.5),
8556 base_volume: Some(12.0),
8557 capacity_volume: Some(80.0),
8558 stackable: Some(false),
8559 world_placeable: Some(false),
8560 worker_lodging_capacity: None,
8561 equip_slot: None,
8562 armor_physical: None,
8563 resists: vec![],
8564 hand_slots: None,
8565 },
8566 );
8567 let opts = state.chest_pickup_destinations("chest-1");
8568 assert!(opts.iter().any(|o| matches!(
8569 &o.kind,
8570 MoveOptionKind::PickupPlaced {
8571 nest_parent_instance_id: None,
8572 ..
8573 }
8574 )));
8575 assert!(opts.iter().any(|o| matches!(
8576 &o.kind,
8577 MoveOptionKind::PickupPlaced {
8578 nest_parent_instance_id: Some(id),
8579 ..
8580 } if *id == back_id
8581 )));
8582 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
8583 }
8584
8585 #[test]
8586 fn placed_container_public_label_hides_owner_custom_name() {
8587 let owner = uuid::Uuid::from_u128(99);
8588 let mut state = sample_state();
8589 state.character_id = Some(uuid::Uuid::from_u128(1));
8590 state.inventory_hints.insert(
8591 "wooden_chest_medium".into(),
8592 InventoryHint {
8593 display_name: "Medium Wooden Chest".into(),
8594 category: "container".into(),
8595 base_mass: None,
8596 base_volume: None,
8597 capacity_volume: None,
8598 stackable: false,
8599 },
8600 );
8601 let chest = flatland_protocol::PlacedContainerView {
8602 id: "c1".into(),
8603 template_id: "wooden_chest_medium".into(),
8604 display_name: "Barry's Loot #a3f2".into(),
8605 x: 128.0,
8606 y: 128.0,
8607 z: 0.0,
8608 locked: false,
8609 accessible: true,
8610 owner_character_id: Some(owner),
8611 contents: vec![],
8612 lock_id: None,
8613 capacity_volume: None,
8614 item_instance_id: None,
8615 tile_id: None,
8616 worker_lodging_capacity: None,
8617 };
8618 assert_eq!(
8619 state.placed_container_public_label(&chest),
8620 "Medium Wooden Chest"
8621 );
8622 state.character_id = Some(owner);
8623 assert_eq!(
8624 state.placed_container_public_label(&chest),
8625 "Barry's Loot #a3f2"
8626 );
8627 }
8628
8629 #[test]
8630 fn location_context_lists_nearby_resource_node() {
8631 let mut state = sample_state();
8632 state.player = state.entities.first().cloned();
8633 state.resource_nodes[0].x = 128.2;
8634 state.resource_nodes[0].y = 128.0;
8635 let lines = state.location_context_lines();
8636 assert!(
8637 lines
8638 .iter()
8639 .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
8640 "expected resource node in context: {:?}",
8641 lines
8642 );
8643 }
8644
8645 #[test]
8646 fn quest_board_usable_within_board_radius() {
8647 let mut state = sample_state();
8648 state.player = state.entities.first().cloned();
8649 state.interactables = vec![flatland_protocol::InteractableView {
8650 id: "board-1".into(),
8651 kind: "quest_board".into(),
8652 label: "Town Quest Board".into(),
8653 x: 130.5,
8654 y: 128.0,
8655 z: 0.0,
8656 board_id: Some("starter_town_board".into()),
8657 }];
8658 assert_eq!(
8660 state.nearest_interact_target().as_deref(),
8661 Some("board-1"),
8662 "quest board should be selectable at ~2.5m"
8663 );
8664 let lines = state.location_context_lines();
8665 assert!(
8666 lines
8667 .iter()
8668 .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
8669 "HUD should advertise f when board is in range: {:?}",
8670 lines
8671 );
8672 }
8673
8674 #[test]
8675 fn inventory_selectable_rows_orders_worn_before_person_before_nearby() {
8676 let mut state = sample_state();
8677 state.worn.insert(
8678 BodySlot::Back,
8679 flatland_protocol::ItemStack {
8680 template_id: "travel_backpack".into(),
8681 quantity: 1,
8682 item_instance_id: Some(uuid::Uuid::from_u128(3)),
8683 props: Default::default(),
8684 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
8685 display_name: None,
8686 category: None,
8687 base_mass: None,
8688 base_volume: None,
8689 capacity_volume: None,
8690 stackable: None,
8691 world_placeable: None,
8692 worker_lodging_capacity: None,
8693 equip_slot: None,
8694 armor_physical: None,
8695 resists: vec![],
8696 hand_slots: None,
8697 },
8698 );
8699 state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
8700 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
8701 id: "chest-1".into(),
8702 template_id: "wooden_chest_small".into(),
8703 display_name: "Wooden Chest".into(),
8704 x: 129.0,
8705 y: 128.0,
8706 z: 0.0,
8707 locked: false,
8708 accessible: true,
8709 owner_character_id: None,
8710 contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
8711 lock_id: None,
8712 capacity_volume: None,
8713 item_instance_id: Some(uuid::Uuid::from_u128(4)),
8714 tile_id: None,
8715 worker_lodging_capacity: None,
8716 }];
8717
8718 let rows = state.inventory_selectable_rows();
8719 let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
8720 assert_eq!(
8721 sections,
8722 vec![
8723 InventorySection::Worn, InventorySection::Worn, InventorySection::Person, InventorySection::Nearby, InventorySection::Nearby, ]
8729 );
8730 assert_eq!(rows[0].stack.template_id, "travel_backpack");
8731 assert!(rows[0].is_equip_shell);
8732 assert_eq!(rows[1].stack.template_id, "iron_ore");
8733 assert_eq!(rows[1].depth, 1);
8734 assert_eq!(rows[2].stack.template_id, "lumber");
8735 assert!(rows[3].is_chest_shell);
8736 assert_eq!(rows[4].stack.template_id, "wood_axe");
8737 assert_eq!(rows[4].depth, 1);
8738
8739 let lines = state.inventory_browser_lines();
8740 assert!(lines.iter().any(|l| matches!(
8741 l,
8742 InventoryBrowserLine::Section(s) if s.contains("Worn")
8743 )));
8744 assert!(lines.iter().any(|l| matches!(
8745 l,
8746 InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
8747 || text.contains("backpack")
8748 )));
8749 }
8750
8751 #[test]
8752 fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
8753 let mut state = sample_state();
8754 let back_id = uuid::Uuid::from_u128(5);
8755 state.worn.insert(
8756 BodySlot::Back,
8757 flatland_protocol::ItemStack {
8758 template_id: "travel_backpack".into(),
8759 quantity: 1,
8760 item_instance_id: Some(back_id),
8761 props: Default::default(),
8762 contents: Vec::new(),
8763 display_name: None,
8764 category: Some("container".into()),
8765 base_mass: None,
8766 base_volume: None,
8767 capacity_volume: Some(80.0),
8768 stackable: None,
8769 world_placeable: None,
8770 worker_lodging_capacity: None,
8771 equip_slot: None,
8772 armor_physical: None,
8773 resists: vec![],
8774 hand_slots: None,
8775 },
8776 );
8777 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
8778 id: "chest-1".into(),
8779 template_id: "wooden_chest_small".into(),
8780 display_name: "Wooden Chest".into(),
8781 x: 129.0,
8782 y: 128.0,
8783 z: 0.0,
8784 locked: false,
8785 accessible: true,
8786 owner_character_id: None,
8787 contents: Vec::new(),
8788 lock_id: None,
8789 capacity_volume: None,
8790 item_instance_id: Some(uuid::Uuid::from_u128(6)),
8791 tile_id: None,
8792 worker_lodging_capacity: None,
8793 }];
8794
8795 let opts = state.move_destinations_for(
8798 &flatland_protocol::InventoryLocation::Root,
8799 None,
8800 None,
8801 "lumber",
8802 );
8803 assert!(!opts.iter().any(|o| matches!(
8804 &o.kind,
8805 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
8806 )));
8807 assert!(opts.iter().any(|o| matches!(
8808 &o.kind,
8809 MoveOptionKind::Move { location, parent_instance_id, .. }
8810 if *location == flatland_protocol::InventoryLocation::Worn {
8811 slot: BodySlot::Back,
8812 } && *parent_instance_id == Some(back_id)
8813 )));
8814 assert!(opts.iter().any(|o| matches!(
8815 &o.kind,
8816 MoveOptionKind::Move { location, .. }
8817 if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
8818 )));
8819 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
8820 assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
8821
8822 let from_backpack = flatland_protocol::InventoryLocation::Worn {
8826 slot: BodySlot::Back,
8827 };
8828 let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
8829 assert!(!opts.iter().any(|o| matches!(
8830 &o.kind,
8831 MoveOptionKind::Move { location, parent_instance_id, .. }
8832 if *location == from_backpack && *parent_instance_id == Some(back_id)
8833 )));
8834 assert!(opts.iter().any(|o| matches!(
8835 &o.kind,
8836 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
8837 )));
8838 }
8839
8840 #[test]
8841 fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
8842 let mut state = sample_state();
8843 state.worn.insert(
8846 BodySlot::Waist,
8847 flatland_protocol::ItemStack {
8848 template_id: "simple_belt".into(),
8849 quantity: 1,
8850 item_instance_id: Some(uuid::Uuid::from_u128(10)),
8851 props: Default::default(),
8852 contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
8853 display_name: None,
8854 category: Some("container".into()),
8855 base_mass: None,
8856 base_volume: None,
8857 capacity_volume: None,
8858 stackable: None,
8859 world_placeable: None,
8860 worker_lodging_capacity: None,
8861 equip_slot: None,
8862 armor_physical: None,
8863 resists: vec![],
8864 hand_slots: None,
8865 },
8866 );
8867 state.worn.insert(
8868 BodySlot::Head,
8869 flatland_protocol::ItemStack {
8870 template_id: "cloth_cap".into(),
8871 quantity: 1,
8872 item_instance_id: Some(uuid::Uuid::from_u128(11)),
8873 props: Default::default(),
8874 contents: Vec::new(),
8875 display_name: None,
8876 category: Some("armor".into()),
8877 base_mass: None,
8878 base_volume: None,
8879 capacity_volume: None,
8880 stackable: None,
8881 world_placeable: None,
8882 worker_lodging_capacity: None,
8883 equip_slot: None,
8884 armor_physical: None,
8885 resists: vec![],
8886 hand_slots: None,
8887 },
8888 );
8889 state.worn.insert(
8890 BodySlot::Back,
8891 flatland_protocol::ItemStack {
8892 template_id: "travel_backpack".into(),
8893 quantity: 1,
8894 item_instance_id: Some(uuid::Uuid::from_u128(12)),
8895 props: Default::default(),
8896 contents: Vec::new(),
8897 display_name: None,
8898 category: Some("container".into()),
8899 base_mass: None,
8900 base_volume: None,
8901 capacity_volume: None,
8902 stackable: None,
8903 world_placeable: None,
8904 worker_lodging_capacity: None,
8905 equip_slot: None,
8906 armor_physical: None,
8907 resists: vec![],
8908 hand_slots: None,
8909 },
8910 );
8911
8912 let rows = state.worn_rows();
8913 assert_eq!(rows.len(), 4);
8915 assert_eq!(rows[0].stack.template_id, "cloth_cap");
8916 assert!(rows[0].is_equip_shell);
8917 assert_eq!(rows[1].stack.template_id, "travel_backpack");
8918 assert!(rows[1].is_equip_shell);
8919 assert_eq!(rows[2].stack.template_id, "simple_belt");
8920 assert!(rows[2].is_equip_shell);
8921 assert_eq!(rows[3].stack.template_id, "leather_pouch");
8922 assert_eq!(rows[3].depth, 1);
8923 assert!(!rows[3].is_equip_shell);
8924 }
8925
8926 #[test]
8927 fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
8928 let mut state = sample_state();
8929 state.worn.insert(
8930 BodySlot::Waist,
8931 flatland_protocol::ItemStack {
8932 template_id: "simple_belt".into(),
8933 quantity: 1,
8934 item_instance_id: Some(uuid::Uuid::from_u128(20)),
8935 props: Default::default(),
8936 contents: Vec::new(),
8937 display_name: Some("Simple Belt".into()),
8938 category: Some("container".into()),
8939 base_mass: None,
8940 base_volume: None,
8941 capacity_volume: None,
8942 stackable: None,
8943 world_placeable: None,
8944 worker_lodging_capacity: None,
8945 equip_slot: None,
8946 armor_physical: None,
8947 resists: vec![],
8948 hand_slots: None,
8949 },
8950 );
8951 state.worn.insert(
8952 BodySlot::Head,
8953 flatland_protocol::ItemStack {
8954 template_id: "cloth_cap".into(),
8955 quantity: 1,
8956 item_instance_id: Some(uuid::Uuid::from_u128(21)),
8957 props: Default::default(),
8958 contents: Vec::new(),
8959 display_name: Some("Cloth Cap".into()),
8960 category: Some("armor".into()),
8961 base_mass: None,
8962 base_volume: None,
8963 capacity_volume: None,
8964 stackable: None,
8965 world_placeable: None,
8966 worker_lodging_capacity: None,
8967 equip_slot: None,
8968 armor_physical: None,
8969 resists: vec![],
8970 hand_slots: None,
8971 },
8972 );
8973
8974 let opts = state.move_destinations_for(
8975 &flatland_protocol::InventoryLocation::Root,
8976 None,
8977 None,
8978 "leather_pouch",
8979 );
8980 assert!(
8981 opts.iter().any(|o| matches!(
8982 &o.kind,
8983 MoveOptionKind::Move { location, .. }
8984 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
8985 )),
8986 "belt loop must be offered when moving a pouch"
8987 );
8988 assert!(
8989 !opts.iter().any(|o| matches!(
8990 &o.kind,
8991 MoveOptionKind::Move { location, .. }
8992 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
8993 )),
8994 "armor slots can't hold other items and must not appear as move destinations"
8995 );
8996 let belt_opt = opts
8997 .iter()
8998 .find(|o| matches!(
8999 &o.kind,
9000 MoveOptionKind::Move { location, .. }
9001 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
9002 ))
9003 .unwrap();
9004 assert!(belt_opt.label.contains("belt loop"));
9005
9006 let opts = state.move_destinations_for(
9007 &flatland_protocol::InventoryLocation::Root,
9008 None,
9009 None,
9010 "lumber",
9011 );
9012 assert!(
9013 !opts.iter().any(|o| o.label.contains("belt loop")),
9014 "loose materials must not target the belt shell — only nested pouches"
9015 );
9016 }
9017
9018 #[test]
9019 fn move_destinations_for_offers_dimensional_pouch_on_belt() {
9020 let mut state = sample_state();
9021 let belt_id = uuid::Uuid::from_u128(30);
9022 let pouch_id = uuid::Uuid::from_u128(31);
9023 state.worn.insert(
9024 BodySlot::Waist,
9025 flatland_protocol::ItemStack {
9026 template_id: "simple_belt".into(),
9027 quantity: 1,
9028 item_instance_id: Some(belt_id),
9029 props: Default::default(),
9030 world_placeable: None,
9031 worker_lodging_capacity: None,
9032 equip_slot: None,
9033 armor_physical: None,
9034 resists: vec![],
9035 hand_slots: None,
9036 contents: vec![flatland_protocol::ItemStack {
9037 template_id: "dimensional_pouch".into(),
9038 quantity: 1,
9039 item_instance_id: Some(pouch_id),
9040 props: Default::default(),
9041 contents: Vec::new(),
9042 display_name: Some("Dimensional Pouch".into()),
9043 category: Some("container".into()),
9044 base_mass: None,
9045 base_volume: None,
9046 capacity_volume: Some(200.0),
9047 stackable: None,
9048 world_placeable: None,
9049 worker_lodging_capacity: None,
9050 equip_slot: None,
9051 armor_physical: None,
9052 resists: vec![],
9053 hand_slots: None,
9054 }],
9055 display_name: Some("Simple Belt".into()),
9056 category: Some("container".into()),
9057 base_mass: None,
9058 base_volume: None,
9059 capacity_volume: None,
9060 stackable: None,
9061 },
9062 );
9063
9064 let opts = state.move_destinations_for(
9065 &flatland_protocol::InventoryLocation::Root,
9066 None,
9067 None,
9068 "iron_ore",
9069 );
9070 assert!(
9071 opts.iter().any(|o| matches!(
9072 &o.kind,
9073 MoveOptionKind::Move {
9074 location,
9075 parent_instance_id,
9076 ..
9077 } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
9078 && *parent_instance_id == Some(pouch_id)
9079 )),
9080 "dimensional pouch clipped on belt must accept loose items"
9081 );
9082 assert!(
9083 opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
9084 "destination label should name the pouch"
9085 );
9086 }
9087
9088 #[test]
9089 fn container_volume_label_on_placed_chest_shell() {
9090 let mut state = sample_state();
9091 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
9092 id: "chest-1".into(),
9093 template_id: "wooden_chest_small".into(),
9094 display_name: "Camp Chest".into(),
9095 worker_lodging_capacity: None,
9096 x: 129.0,
9097 y: 128.0,
9098 z: 0.0,
9099 locked: false,
9100 accessible: true,
9101 owner_character_id: None,
9102 contents: vec![flatland_protocol::ItemStack {
9103 template_id: "iron_ore".into(),
9104 quantity: 2,
9105 item_instance_id: None,
9106 props: Default::default(),
9107 contents: Vec::new(),
9108 display_name: None,
9109 category: None,
9110 base_mass: None,
9111 base_volume: Some(2.0),
9112 capacity_volume: None,
9113 stackable: None,
9114 world_placeable: None,
9115 worker_lodging_capacity: None,
9116 equip_slot: None,
9117 armor_physical: None,
9118 resists: vec![],
9119 hand_slots: None,
9120 }],
9121 lock_id: None,
9122 capacity_volume: Some(60.0),
9123 item_instance_id: Some(uuid::Uuid::from_u128(4)),
9124 tile_id: None,
9125 }];
9126 let nearby = state.nearby_containers();
9127 let label = state.container_volume_label(&nearby[0].rows[0]);
9128 assert!(
9129 label.contains("vol 4/60"),
9130 "expected used/cap in label, got {label}"
9131 );
9132 assert!(
9133 label.contains("56 free"),
9134 "expected free space, got {label}"
9135 );
9136 }
9137
9138 #[test]
9139 fn key_pair_chest_label_from_placed_lock_id() {
9140 let mut state = sample_state();
9141 let owner = uuid::Uuid::from_u128(77);
9142 state.character_id = Some(owner);
9143 let lock = uuid::Uuid::from_u128(99).to_string();
9144 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
9145 id: "chest-1".into(),
9146 template_id: "wooden_chest_small".into(),
9147 display_name: "Barry's Loot #a3f2".into(),
9148 x: 129.0,
9149 y: 128.0,
9150 z: 0.0,
9151 locked: true,
9152 accessible: true,
9153 owner_character_id: Some(owner),
9154 contents: Vec::new(),
9155 lock_id: Some(lock.clone()),
9156 capacity_volume: None,
9157 item_instance_id: Some(uuid::Uuid::from_u128(4)),
9158 tile_id: None,
9159 worker_lodging_capacity: None,
9160 }];
9161 let key_id = uuid::Uuid::from_u128(5);
9162 let key = flatland_protocol::ItemStack {
9163 template_id: KEY_TEMPLATE.into(),
9164 quantity: 1,
9165 item_instance_id: Some(key_id),
9166 props: BTreeMap::from([
9167 (PROP_OPENS_LOCK_ID.into(), lock),
9168 (
9169 PROP_OPENS_CONTAINER_NAME.into(),
9170 "Barry's Loot #a3f2".into(),
9171 ),
9172 ]),
9173 contents: Vec::new(),
9174 display_name: Some("Container Key".into()),
9175 category: Some("key".into()),
9176 base_mass: None,
9177 base_volume: None,
9178 capacity_volume: None,
9179 stackable: None,
9180 world_placeable: None,
9181 worker_lodging_capacity: None,
9182 equip_slot: None,
9183 armor_physical: None,
9184 resists: vec![],
9185 hand_slots: None,
9186 };
9187 state.inventory_stacks = vec![key.clone()];
9188 assert_eq!(
9189 state.key_pair_chest_label(&key).as_deref(),
9190 Some("Barry's Loot #a3f2")
9191 );
9192 assert!(state.key_drop_blocked(&key));
9193 }
9194
9195 #[test]
9196 fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
9197 let mut state = sample_state();
9198 let lock = uuid::Uuid::from_u128(101).to_string();
9199 let key = flatland_protocol::ItemStack {
9200 template_id: KEY_TEMPLATE.into(),
9201 quantity: 1,
9202 item_instance_id: Some(uuid::Uuid::from_u128(7)),
9203 props: BTreeMap::from([
9204 (PROP_OPENS_LOCK_ID.into(), lock),
9205 (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
9206 ]),
9207 contents: Vec::new(),
9208 display_name: None,
9209 category: Some("key".into()),
9210 base_mass: None,
9211 base_volume: None,
9212 capacity_volume: None,
9213 stackable: None,
9214 world_placeable: None,
9215 worker_lodging_capacity: None,
9216 equip_slot: None,
9217 armor_physical: None,
9218 resists: vec![],
9219 hand_slots: None,
9220 };
9221 state.placed_containers.clear();
9222 assert_eq!(
9223 state.key_pair_chest_label(&key).as_deref(),
9224 Some("Camp Stash")
9225 );
9226 }
9227
9228 #[test]
9229 fn key_drop_allowed_when_paired_chest_unlocked() {
9230 let mut state = sample_state();
9231 let lock = uuid::Uuid::from_u128(100).to_string();
9232 let key_id = uuid::Uuid::from_u128(6);
9233 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
9234 id: "chest-1".into(),
9235 template_id: "wooden_chest_small".into(),
9236 display_name: "Camp Chest".into(),
9237 x: 129.0,
9238 y: 128.0,
9239 z: 0.0,
9240 locked: false,
9241 accessible: true,
9242 owner_character_id: None,
9243 contents: Vec::new(),
9244 lock_id: Some(lock.clone()),
9245 capacity_volume: None,
9246 item_instance_id: None,
9247 tile_id: None,
9248 worker_lodging_capacity: None,
9249 }];
9250 let key = flatland_protocol::ItemStack {
9251 template_id: KEY_TEMPLATE.into(),
9252 quantity: 1,
9253 item_instance_id: Some(key_id),
9254 props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
9255 contents: Vec::new(),
9256 display_name: None,
9257 category: Some("key".into()),
9258 base_mass: None,
9259 base_volume: None,
9260 capacity_volume: None,
9261 stackable: None,
9262 world_placeable: None,
9263 worker_lodging_capacity: None,
9264 equip_slot: None,
9265 armor_physical: None,
9266 resists: vec![],
9267 hand_slots: None,
9268 };
9269 state.inventory_stacks = vec![key.clone()];
9270 assert!(!state.key_drop_blocked(&key));
9271 let opts = state.move_destinations_for(
9272 &flatland_protocol::InventoryLocation::Root,
9273 None,
9274 Some(key_id),
9275 KEY_TEMPLATE,
9276 );
9277 assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
9278 }
9279
9280 #[test]
9281 fn combat_hud_refreshes_progression_xp_when_entity_stale() {
9282 use flatland_protocol::{CombatHud, ProgressionXp, ProgressionCurve};
9283
9284 let mut state = sample_state();
9285 let curve = ProgressionCurve::default();
9286 let bootstrap = ProgressionXp::bootstrap_new(
9287 curve.baseline_display,
9288 curve.xp_base,
9289 curve.xp_growth,
9290 );
9291 let mut fresh = bootstrap.clone();
9292 fresh.strength += 0.08;
9293 if let Some(player) = state.player.as_mut() {
9294 player.progression_xp = Some(bootstrap);
9295 }
9296
9297 let combat = CombatHud {
9298 progression_xp: Some(fresh.clone()),
9299 progression_baseline: curve.baseline_display,
9300 progression_xp_base: curve.xp_base,
9301 progression_xp_growth: curve.xp_growth,
9302 attributes: state.player.as_ref().and_then(|p| p.attributes),
9303 skills: state.player.as_ref().and_then(|p| p.skills.clone()),
9304 ..CombatHud::default()
9305 };
9306 state.apply_combat_hud(&combat);
9307
9308 let xp = state
9309 .player
9310 .as_ref()
9311 .and_then(|p| p.progression_xp.as_ref())
9312 .expect("xp");
9313 assert!((xp.strength - fresh.strength).abs() < 0.001);
9314 assert!(state.progression_curve.is_some());
9315 }
9316
9317 #[test]
9318 fn loose_consumable_move_picker_offers_use_and_storage() {
9319 let mut state = sample_state();
9320 let inst = uuid::Uuid::from_u128(77);
9321 state.inventory_stacks = vec![flatland_protocol::ItemStack {
9322 template_id: "carrot".into(),
9323 quantity: 2,
9324 item_instance_id: Some(inst),
9325 props: Default::default(),
9326 contents: Vec::new(),
9327 display_name: Some("Wild Carrot".into()),
9328 category: Some("consumable".into()),
9329 base_mass: None,
9330 base_volume: None,
9331 capacity_volume: None,
9332 stackable: Some(true),
9333 world_placeable: None,
9334 worker_lodging_capacity: None,
9335 equip_slot: None,
9336 armor_physical: None,
9337 resists: vec![],
9338 hand_slots: None,
9339 }];
9340 state.inventory_hints.insert(
9341 "carrot".into(),
9342 InventoryHint {
9343 display_name: "Wild Carrot".into(),
9344 category: "consumable".into(),
9345 base_mass: Some(0.15),
9346 base_volume: Some(0.3),
9347 capacity_volume: None,
9348 stackable: true,
9349 },
9350 );
9351 state.show_inventory_menu = true;
9352 state.inventory_menu_index = 0;
9353
9354 let row = state.inventory_selected_row().expect("carrot row");
9355 let mut options = state.move_destinations_for(
9356 &row.from,
9357 row.from_parent_instance_id,
9358 row.stack.item_instance_id,
9359 &row.stack.template_id,
9360 );
9361 if row.from == flatland_protocol::InventoryLocation::Root
9362 && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
9363 {
9364 options.insert(
9365 0,
9366 MoveOption {
9367 label: "Use (eat / drink)".into(),
9368 kind: MoveOptionKind::Use,
9369 },
9370 );
9371 }
9372
9373 assert_eq!(options.first().map(|o| &o.label), Some(&"Use (eat / drink)".into()));
9374 assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
9375 assert!(options.iter().any(|o| matches!(o.kind, MoveOptionKind::Drop)));
9376 }
9377}