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