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(crate) fn restore_from_welcome(
2332 &mut self,
2333 session_id: SessionId,
2334 entity_id: EntityId,
2335 snapshot: &flatland_protocol::Snapshot,
2336 ) {
2337 self.clear_harvest_state();
2338 self.disconnect_reason = None;
2339 self.show_stats = false;
2340 self.show_craft_menu = false;
2341 self.show_shop_menu = false;
2342 self.shop_catalog = None;
2343 self.show_inventory_menu = false;
2344 self.session_id = session_id;
2345 self.entity_id = entity_id;
2346 self.connected = true;
2347 self.apply_snapshot_fields(snapshot, entity_id);
2348 if let Some(combat) = &snapshot.combat {
2349 self.apply_combat_hud(combat);
2350 let stacks = self.inventory_stacks.clone();
2351 self.sync_inventory_from_stacks(&stacks);
2352 }
2353 }
2354
2355 fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
2356 self.tick = delta.tick;
2357 self.world_clock = delta.world_clock;
2358
2359 if delta.entities.is_empty() {
2361 if let Some(combat) = &delta.combat {
2362 self.apply_combat_hud(combat);
2363 let stacks = self.inventory_stacks.clone();
2364 self.sync_inventory_from_stacks(&stacks);
2365 }
2366 return;
2367 }
2368 if !delta.buildings.is_empty() {
2369 self.buildings = delta.buildings.clone();
2370 }
2371 if !delta.blueprints.is_empty() {
2372 self.blueprints = delta.blueprints.clone();
2373 }
2374 self.sync_inventory_from_stacks(&delta.inventory);
2375
2376 if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
2377 self.player = Some(updated.clone());
2378 }
2379 self.entities = delta.entities.clone();
2380 if self.player.is_none() {
2381 self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
2382 }
2383
2384 self.sync_interior_map_context();
2385
2386 if !delta.resource_nodes.is_empty() {
2389 self.resource_nodes = delta.resource_nodes.clone();
2390 }
2391 if self
2392 .player
2393 .as_ref()
2394 .is_none_or(|p| p.inside_building.is_none())
2395 {
2396 self.ground_drops = delta.ground_drops.clone();
2397 self.placed_containers = delta.placed_containers.clone();
2398 }
2399 if !delta.doors.is_empty() {
2400 self.doors = delta.doors.clone();
2401 }
2402 if self.effective_inside_building().is_some() {
2403 if let Some(map) = &delta.interior_map {
2404 self.interior_map = Some(map.clone());
2405 }
2406 } else {
2407 self.interior_map = None;
2408 }
2409 if !delta.npcs.is_empty() {
2410 self.npcs = delta.npcs.clone();
2411 }
2412 if !delta.quest_log.is_empty() {
2413 self.quest_log = delta.quest_log.clone();
2414 }
2415 self.apply_hired_workers(delta.hired_workers.clone());
2416 if !delta.interactables.is_empty() {
2417 self.interactables = delta.interactables.clone();
2418 }
2419 if let Some(combat) = &delta.combat {
2420 self.apply_combat_hud(combat);
2421 let stacks = self.inventory_stacks.clone();
2422 self.sync_inventory_from_stacks(&stacks);
2423 } else {
2424 self.refresh_inventory_ui();
2425 }
2426 }
2427
2428 pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
2430 let (px, py) = self.player_position();
2431 let mut out = Vec::new();
2432 for npc in &self.npcs {
2433 let Some(eid) = npc.entity_id else {
2434 continue;
2435 };
2436 let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
2437 let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
2438 if alive && has_hp {
2439 out.push((eid, npc.label.clone()));
2440 }
2441 }
2442 out.sort_by(|(a_id, a_label), (b_id, b_label)| {
2443 let dist = |id: EntityId| {
2444 self.entities
2445 .iter()
2446 .find(|e| e.id == id)
2447 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
2448 .unwrap_or(f32::MAX)
2449 };
2450 dist(*a_id)
2451 .partial_cmp(&dist(*b_id))
2452 .unwrap_or(std::cmp::Ordering::Equal)
2453 .then_with(|| a_label.cmp(b_label))
2454 .then_with(|| a_id.cmp(b_id))
2455 });
2456 out
2457 }
2458
2459 pub fn refresh_combat_target_label(&mut self) {
2460 let Some(id) = self.combat_target else {
2461 return;
2462 };
2463 if let Some((_, label)) = self
2464 .combat_candidates()
2465 .into_iter()
2466 .find(|(eid, _)| *eid == id)
2467 {
2468 self.combat_target_label = Some(label);
2469 } else if let Some(label) = self
2470 .entities
2471 .iter()
2472 .find(|e| e.id == id)
2473 .map(|e| e.label.clone())
2474 {
2475 self.combat_target_label = Some(label);
2476 }
2477 }
2478
2479 pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
2480 self.quest_log
2481 .iter()
2482 .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
2483 .collect()
2484 }
2485
2486 pub fn has_worker_lodging(&self) -> bool {
2488 self.free_worker_lodging_slots() > 0
2489 }
2490
2491 pub fn free_worker_lodging_slots(&self) -> i64 {
2493 let slots: u32 = self
2494 .placed_containers
2495 .iter()
2496 .filter(|c| match (self.character_id, c.owner_character_id) {
2497 (Some(me), Some(owner)) => me == owner,
2498 (Some(_), None) => false,
2499 (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
2500 })
2501 .map(|c| c.worker_lodging_capacity.unwrap_or(0))
2502 .sum();
2503 let used = self.hired_workers.len() as u32;
2504 slots as i64 - used as i64
2505 }
2506
2507 pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
2509 let mut names: Vec<String> = self
2510 .hired_workers
2511 .iter()
2512 .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
2513 .map(|w| w.label.clone())
2514 .collect();
2515 names.sort();
2516 names
2517 }
2518
2519 pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
2521 let is_lodging = self
2522 .placed_containers
2523 .iter()
2524 .find(|c| c.id == container_id)
2525 .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
2526 if !is_lodging {
2527 return None;
2528 }
2529 let names = self.lodging_occupant_labels(container_id);
2530 Some(if names.is_empty() {
2531 "vacant".into()
2532 } else {
2533 names.join(", ")
2534 })
2535 }
2536
2537 pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
2538 self.quest_log
2539 .iter()
2540 .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
2541 .or_else(|| {
2542 self.quest_log
2543 .iter()
2544 .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
2545 })
2546 }
2547
2548 pub fn nearest_interact_target(&self) -> Option<String> {
2550 let (px, py) = self.player_position();
2551 let inside = self.effective_inside_building();
2552
2553 #[derive(Clone, Copy, PartialEq, Eq)]
2554 enum Kind {
2555 Npc,
2556 QuestBoard,
2557 ExitDoor,
2558 EnterDoor,
2559 Well,
2560 Water,
2561 }
2562
2563 fn kind_priority(kind: Kind) -> u8 {
2564 match kind {
2565 Kind::Npc => 0,
2566 Kind::QuestBoard => 1,
2567 Kind::ExitDoor => 2,
2568 Kind::EnterDoor => 3,
2569 Kind::Well => 4,
2570 Kind::Water => 5,
2571 }
2572 }
2573
2574 let mut best: Option<(f32, Kind, String)> = None;
2575
2576 let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
2577 if dist > max {
2578 return;
2579 }
2580 let replace = match best {
2581 None => true,
2582 Some((bd, _bk, _)) if dist < bd - 0.05 => true,
2583 Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
2584 kind_priority(kind) < kind_priority(bk)
2585 }
2586 _ => false,
2587 };
2588 if replace {
2589 best = Some((dist, kind, id));
2590 }
2591 };
2592
2593 for npc in &self.npcs {
2594 consider(
2595 distance(px, py, npc.x, npc.y),
2596 INTERACTION_RADIUS_M,
2597 Kind::Npc,
2598 npc.id.clone(),
2599 );
2600 }
2601
2602 for door in &self.doors {
2603 if let Some(ref bid) = inside {
2604 if door.building_id != *bid {
2605 continue;
2606 }
2607 let is_exit = door.portal.is_some();
2608 let max = if is_exit {
2609 INTERACTION_RADIUS_M
2610 } else {
2611 DOOR_INTERACTION_RADIUS_M
2612 };
2613 let kind = if is_exit {
2614 Kind::ExitDoor
2615 } else {
2616 Kind::EnterDoor
2617 };
2618 consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
2619 continue;
2620 }
2621 consider(
2622 distance(px, py, door.x, door.y),
2623 DOOR_INTERACTION_RADIUS_M,
2624 Kind::EnterDoor,
2625 door.id.clone(),
2626 );
2627 }
2628
2629 if inside.is_none() {
2630 for inter in &self.interactables {
2631 if inter.kind == "quest_board" {
2632 consider(
2633 distance(px, py, inter.x, inter.y),
2634 QUEST_BOARD_INTERACTION_RADIUS_M,
2635 Kind::QuestBoard,
2636 inter.id.clone(),
2637 );
2638 }
2639 }
2640 for building in &self.buildings {
2641 if !building.tags.iter().any(|t| t == "well") {
2642 continue;
2643 }
2644 consider(
2645 distance(px, py, building.x, building.y),
2646 INTERACTION_RADIUS_M,
2647 Kind::Well,
2648 building.id.clone(),
2649 );
2650 }
2651 if self.in_shallow_water() {
2652 consider(
2653 0.0,
2654 INTERACTION_RADIUS_M,
2655 Kind::Water,
2656 "water_source".into(),
2657 );
2658 }
2659 }
2660
2661 best.map(|(_, _, id)| id)
2662 }
2663
2664 pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
2666 if self.effective_inside_building().is_some() {
2667 return None;
2668 }
2669 let (px, py) = self.player_position();
2670 self.interactables
2671 .iter()
2672 .filter(|i| i.kind == "quest_board")
2673 .map(|i| {
2674 let label = if i.label.is_empty() {
2675 "Quest board".to_string()
2676 } else {
2677 i.label.clone()
2678 };
2679 (label, distance(px, py, i.x, i.y))
2680 })
2681 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
2682 }
2683
2684 pub fn template_display_name(&self, template_id: &str) -> String {
2686 self.inventory_hints
2687 .get(template_id)
2688 .map(|h| h.display_name.clone())
2689 .filter(|n| !n.is_empty())
2690 .unwrap_or_else(|| humanize_template_id(template_id))
2691 }
2692
2693 pub fn placed_container_public_label(
2695 &self,
2696 c: &flatland_protocol::PlacedContainerView,
2697 ) -> String {
2698 let is_owner = match (self.character_id, c.owner_character_id) {
2699 (Some(me), Some(owner)) => me == owner,
2700 _ => false,
2701 };
2702 if is_owner {
2703 c.display_name.clone()
2704 } else {
2705 self.template_display_name(&c.template_id)
2706 }
2707 }
2708
2709 pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
2711 let mut out = Vec::new();
2712 for stack in &self.inventory_stacks {
2713 if stack.template_id == KEY_TEMPLATE {
2714 out.push(KeychainEntry {
2715 stack: stack.clone(),
2716 stowed: false,
2717 });
2718 }
2719 }
2720 for stack in &self.keychain_stacks {
2721 if stack.template_id == KEY_TEMPLATE {
2722 out.push(KeychainEntry {
2723 stack: stack.clone(),
2724 stowed: true,
2725 });
2726 }
2727 }
2728 out
2729 }
2730
2731 pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
2733 if stack.template_id != KEY_TEMPLATE {
2734 return None;
2735 }
2736 if let Some(name) = stack
2737 .props
2738 .get(PROP_OPENS_CONTAINER_NAME)
2739 .filter(|n| !n.is_empty())
2740 {
2741 return Some(name.clone());
2742 }
2743 let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
2744 self.container_name_for_lock_id(opens)
2745 }
2746
2747 pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
2749 if stack.template_id == KEY_TEMPLATE {
2750 self.template_display_name(KEY_TEMPLATE)
2751 } else {
2752 stack
2753 .display_name
2754 .clone()
2755 .unwrap_or_else(|| stack.template_id.clone())
2756 }
2757 }
2758
2759 pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
2761 if stack.template_id != KEY_TEMPLATE {
2762 return String::new();
2763 }
2764 match self.key_pair_chest_label(stack) {
2765 Some(chest) if self.key_drop_blocked(stack) => {
2766 format!(" [key for {chest} — can't drop while locked]")
2767 }
2768 Some(chest) => format!(" [key for {chest}]"),
2769 None => " [key — unpaired]".into(),
2770 }
2771 }
2772
2773 pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
2775 for c in &self.placed_containers {
2776 if c.lock_id.as_deref() == Some(lock) {
2777 return Some(c.display_name.clone());
2778 }
2779 }
2780 Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
2781 self.worn
2782 .values()
2783 .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
2784 })
2785 }
2786
2787 pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
2789 if stack.template_id != KEY_TEMPLATE {
2790 return false;
2791 }
2792 let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
2793 return false;
2794 };
2795 for c in &self.placed_containers {
2796 if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
2797 return true;
2798 }
2799 }
2800 if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
2801 return true;
2802 }
2803 self.worn
2804 .values()
2805 .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
2806 }
2807
2808 fn container_name_in_stacks(
2809 stacks: &[flatland_protocol::ItemStack],
2810 lock: &str,
2811 ) -> Option<String> {
2812 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
2813 for s in stacks {
2814 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
2815 return Some(GameState::stack_container_label(s));
2816 }
2817 if let Some(name) = walk(&s.contents, lock) {
2818 return Some(name);
2819 }
2820 }
2821 None
2822 }
2823 walk(stacks, lock)
2824 }
2825
2826 fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
2827 stack
2828 .props
2829 .get(PROP_CUSTOM_NAME)
2830 .cloned()
2831 .or_else(|| stack.display_name.clone())
2832 .unwrap_or_else(|| stack.template_id.clone())
2833 }
2834
2835 fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
2836 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
2837 for s in stacks {
2838 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
2839 return true;
2840 }
2841 if walk(&s.contents, lock) {
2842 return true;
2843 }
2844 }
2845 false
2846 }
2847 walk(stacks, lock)
2848 }
2849
2850 fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
2851 if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
2852 return Some(stack.clone());
2853 }
2854 for worn in self.worn.values() {
2855 if worn.item_instance_id == Some(instance_id) {
2856 return Some(worn.clone());
2857 }
2858 if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
2859 return Some(stack.clone());
2860 }
2861 }
2862 None
2863 }
2864
2865 pub fn location_context_lines(&self) -> Vec<ContextLine> {
2867 let (px, py) = self.player_position();
2868 let inside = self.effective_inside_building();
2869 let mut lines = Vec::new();
2870
2871 if let Some(kind) = self.terrain_at(px, py) {
2872 lines.push(ContextLine {
2873 on_top: true,
2874 text: format!("Terrain: {}", terrain_kind_label(kind)),
2875 });
2876 }
2877
2878 if let Some(id) = inside.as_ref() {
2879 if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
2880 lines.push(ContextLine {
2881 on_top: true,
2882 text: format!("Inside: {}", b.label),
2883 });
2884 }
2885 }
2886
2887 let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
2888
2889 for node in &self.resource_nodes {
2890 let dist = distance(px, py, node.x, node.y);
2891 if dist > NEARBY_SCAN_M {
2892 continue;
2893 }
2894 let on_top = dist <= ON_TOP_RADIUS_M;
2895 let state = match node.state {
2896 flatland_protocol::ResourceNodeState::Available => " — f harvest",
2897 flatland_protocol::ResourceNodeState::Harvesting => " (being harvested)",
2898 flatland_protocol::ResourceNodeState::Cooldown => " (depleted)",
2899 };
2900 let prefix = if on_top { "On" } else { "Near" };
2901 nearby.push((
2902 dist,
2903 ContextLine {
2904 on_top,
2905 text: format!("{prefix}: {} ({dist:.1}m){state}", node.label),
2906 },
2907 ));
2908 }
2909
2910 for drop in &self.ground_drops {
2911 let dist = distance(px, py, drop.x, drop.y);
2912 if dist > INTERACTION_RADIUS_M {
2913 continue;
2914 }
2915 let on_top = dist <= ON_TOP_RADIUS_M;
2916 let name = self.template_display_name(&drop.template_id);
2917 let prefix = if on_top { "On" } else { "Near" };
2918 let qty = if drop.quantity > 1 {
2919 format!(" ×{}", drop.quantity)
2920 } else {
2921 String::new()
2922 };
2923 nearby.push((
2924 dist,
2925 ContextLine {
2926 on_top,
2927 text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
2928 },
2929 ));
2930 }
2931
2932 for c in &self.placed_containers {
2933 let dist = distance(px, py, c.x, c.y);
2934 if dist > CONTAINER_RANGE_M {
2935 continue;
2936 }
2937 let on_top = dist <= ON_TOP_RADIUS_M;
2938 let name = self.placed_container_public_label(c);
2939 let lock = if c.locked { " [locked]" } else { "" };
2940 let prefix = if on_top { "On" } else { "Near" };
2941 nearby.push((
2942 dist,
2943 ContextLine {
2944 on_top,
2945 text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
2946 },
2947 ));
2948 }
2949
2950 for npc in &self.npcs {
2951 let dist = distance(px, py, npc.x, npc.y);
2952 if dist > NEARBY_SCAN_M {
2953 continue;
2954 }
2955 let on_top = dist <= ON_TOP_RADIUS_M;
2956 let prefix = if on_top { "On" } else { "Near" };
2957 nearby.push((
2958 dist,
2959 ContextLine {
2960 on_top,
2961 text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
2962 },
2963 ));
2964 }
2965
2966 for door in &self.doors {
2967 let dist = distance(px, py, door.x, door.y);
2968 if dist > DOOR_INTERACTION_RADIUS_M {
2969 continue;
2970 }
2971 let building = self
2972 .buildings
2973 .iter()
2974 .find(|b| b.id == door.building_id)
2975 .map(|b| b.label.as_str())
2976 .unwrap_or(door.building_id.as_str());
2977 let action = if inside.is_some() && door.portal.is_some() {
2978 "exit"
2979 } else {
2980 "enter"
2981 };
2982 nearby.push((
2983 dist,
2984 ContextLine {
2985 on_top: dist <= ON_TOP_RADIUS_M,
2986 text: format!("{building} door ({dist:.1}m) — f {action}"),
2987 },
2988 ));
2989 }
2990
2991 if inside.is_none() {
2992 for inter in &self.interactables {
2993 if inter.kind != "quest_board" {
2994 continue;
2995 }
2996 let dist = distance(px, py, inter.x, inter.y);
2997 if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
2998 continue;
2999 }
3000 let on_top = dist <= ON_TOP_RADIUS_M;
3001 let prefix = if on_top { "On" } else { "Near" };
3002 let label = if inter.label.is_empty() {
3003 "Quest board".to_string()
3004 } else {
3005 inter.label.clone()
3006 };
3007 nearby.push((
3008 dist,
3009 ContextLine {
3010 on_top,
3011 text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
3012 },
3013 ));
3014 }
3015 }
3016
3017 if self.in_shallow_water() {
3018 let already = self
3019 .terrain_at(px, py)
3020 .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
3021 if !already {
3022 nearby.push((
3023 0.0,
3024 ContextLine {
3025 on_top: true,
3026 text: "Shallow water — f fill bottle".into(),
3027 },
3028 ));
3029 } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
3030 line.text.push_str(" — f fill bottle");
3031 }
3032 }
3033
3034 for entity in &self.entities {
3035 if entity.id == self.entity_id {
3036 continue;
3037 }
3038 let dist = distance(
3039 px,
3040 py,
3041 entity.transform.position.x,
3042 entity.transform.position.y,
3043 );
3044 if dist > NEARBY_SCAN_M {
3045 continue;
3046 }
3047 let label = if entity.label.is_empty() {
3048 format!("entity {}", entity.id)
3049 } else {
3050 entity.label.clone()
3051 };
3052 nearby.push((
3053 dist,
3054 ContextLine {
3055 on_top: dist <= ON_TOP_RADIUS_M,
3056 text: format!("Near: {label} ({dist:.1}m)"),
3057 },
3058 ));
3059 }
3060
3061 nearby.sort_by(|a, b| {
3062 a.0.partial_cmp(&b.0)
3063 .unwrap_or(std::cmp::Ordering::Equal)
3064 .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
3065 });
3066 lines.extend(nearby.into_iter().map(|(_, l)| l));
3067
3068 if lines.is_empty() {
3069 lines.push(ContextLine {
3070 on_top: false,
3071 text: "(nothing notable nearby)".into(),
3072 });
3073 }
3074
3075 lines
3076 }
3077}
3078
3079#[derive(Debug, Clone)]
3081pub struct ContextLine {
3082 pub on_top: bool,
3083 pub text: String,
3084}
3085
3086const ON_TOP_RADIUS_M: f32 = 0.65;
3087const NEARBY_SCAN_M: f32 = 5.0;
3088
3089fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
3090 use flatland_protocol::TerrainKindView;
3091 match kind {
3092 TerrainKindView::Grass => "Grass",
3093 TerrainKindView::Hill => "Hills",
3094 TerrainKindView::Bog => "Bog",
3095 TerrainKindView::ShallowWater => "Shallow water",
3096 TerrainKindView::DeepWater => "Deep water",
3097 TerrainKindView::Trail => "Trail",
3098 TerrainKindView::Rock => "Rock",
3099 }
3100}
3101
3102fn humanize_template_id(template_id: &str) -> String {
3103 template_id
3104 .split('_')
3105 .map(|word| {
3106 let mut chars = word.chars();
3107 match chars.next() {
3108 None => String::new(),
3109 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
3110 }
3111 })
3112 .collect::<Vec<_>>()
3113 .join(" ")
3114}
3115
3116const HARVEST_RANGE_M: f32 = 1.5;
3118
3119pub struct GameClient<S: PlayConnection> {
3120 session: S,
3121 seq: Seq,
3122 pub state: GameState,
3123 last_move_forward: f32,
3124 last_move_strafe: f32,
3125}
3126
3127impl<S: PlayConnection> GameClient<S> {
3128 pub fn new(session: S) -> Self {
3129 let session_id = session.session_id();
3130 let entity_id = session.entity_id();
3131 Self {
3132 session,
3133 seq: 0,
3134 last_move_forward: 0.0,
3135 last_move_strafe: 0.0,
3136 state: GameState {
3137 session_id,
3138 entity_id,
3139 character_id: None,
3140 tick: 0,
3141 chunk_rev: 0,
3142 content_rev: 0,
3143 publish_rev: 0,
3144 entities: Vec::new(),
3145 player: None,
3146 resource_nodes: Vec::new(),
3147 ground_drops: Vec::new(),
3148 placed_containers: Vec::new(),
3149 buildings: Vec::new(),
3150 doors: Vec::new(),
3151 interior_map: None,
3152 npcs: Vec::new(),
3153 blueprints: Vec::new(),
3154 world_width_m: 0.0,
3155 world_height_m: 0.0,
3156 terrain_zones: Vec::new(),
3157 z_platforms: Vec::new(),
3158 z_transitions: Vec::new(),
3159 world_clock: flatland_protocol::WorldClock::default(),
3160 inventory: std::collections::HashMap::new(),
3161 inventory_hints: std::collections::HashMap::new(),
3162 logs: VecDeque::new(),
3163 intents_sent: 0,
3164 ticks_received: 0,
3165 connected: false,
3166 disconnect_reason: None,
3167 show_stats: false,
3168 show_craft_menu: false,
3169 craft_menu_index: 0,
3170 craft_batch_quantity: 1,
3171 show_shop_menu: false,
3172 shop_catalog: None,
3173 shop_tab: ShopTab::default(),
3174 shop_menu_index: 0,
3175 shop_quantity: 1,
3176 shop_trade_log: VecDeque::new(),
3177 show_npc_verb_menu: false,
3178 npc_verb_target: None,
3179 npc_verb_index: 0,
3180 show_npc_chat: false,
3181 npc_chat: None,
3182 show_inventory_menu: false,
3183 inventory_menu_index: 0,
3184 show_move_picker: false,
3185 show_rename_prompt: false,
3186 show_worker_rename: false,
3187 rename_buffer: String::new(),
3188 move_picker_index: 0,
3189 move_picker: None,
3190 show_destroy_picker: false,
3191 destroy_confirm_pending: false,
3192 destroy_picker: None,
3193 combat_target: None,
3194 combat_target_label: None,
3195 in_combat: false,
3196 auto_attack: true,
3197 combat_has_los: false,
3198 attack_cd_ticks: 0,
3199 gcd_ticks: 0,
3200 weapon_ability_id: "unarmed".into(),
3201 mainhand_template_id: None,
3202 mainhand_label: None,
3203 worn: BTreeMap::new(),
3204 carry_mass: 0.0,
3205 carry_mass_max: 0.0,
3206 encumbrance: flatland_protocol::EncumbranceState::Light,
3207 inventory_stacks: Vec::new(),
3208 keychain_stacks: Vec::new(),
3209 combat_target_detail: None,
3210 cast_progress: None,
3211 ability_cooldowns: Vec::new(),
3212 blocking_active: false,
3213 max_target_slots: 1,
3214 combat_slots: Vec::new(),
3215 rotation_presets: Vec::new(),
3216 show_loadout_menu: false,
3217 show_keychain_menu: false,
3218 keychain_menu_index: 0,
3219 show_rotation_editor: false,
3220 loadout_menu_index: 0,
3221 rotation_editor: RotationEditorState::default(),
3222 harvest_in_progress: false,
3223 harvest_started_at: None,
3224 pending_craft_ack: None,
3225 pending_worker_job_ack: None,
3226 quest_log: Vec::new(),
3227 interactables: Vec::new(),
3228 show_quest_offer: false,
3229 pending_quest_offer: None,
3230 show_quest_menu: false,
3231 quest_menu_index: 0,
3232 quest_withdraw_confirm: false,
3233 hired_workers: Vec::new(),
3234 show_workers_menu: false,
3235 workers_menu_index: 0,
3236 workers_menu_compact: false,
3237 worker_step_display: BTreeMap::new(),
3238 show_worker_give_picker: false,
3239 worker_give_picker_index: 0,
3240 worker_give_picker: None,
3241 show_worker_give_target_picker: false,
3242 worker_give_target_picker_index: 0,
3243 worker_give_target_picker: None,
3244 show_worker_take_picker: false,
3245 worker_take_picker_index: 0,
3246 worker_take_picker: None,
3247 show_worker_teach_picker: false,
3248 worker_teach_picker_index: 0,
3249 worker_teach_picker: None,
3250 worker_route_editor: None,
3251 progression_curve: None,
3252 },
3253 }
3254 }
3255
3256 pub fn entity_id(&self) -> EntityId {
3257 self.state.entity_id
3258 }
3259
3260 pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
3261 if self.state.connected {
3262 return Ok(());
3263 }
3264
3265 loop {
3266 match self.session.next_event().await {
3267 Some(SessionEvent::Welcome {
3268 session_id,
3269 entity_id,
3270 snapshot,
3271 }) => {
3272 self.state
3273 .restore_from_welcome(session_id, entity_id, &snapshot);
3274 self.state.push_log(format!(
3275 "Connected — session {session_id}, entity {entity_id}"
3276 ));
3277 return Ok(());
3278 }
3279 Some(SessionEvent::Disconnected { .. }) => {
3280 anyhow::bail!("disconnected before welcome");
3281 }
3282 Some(_) => continue,
3283 None => anyhow::bail!("session closed before welcome"),
3284 }
3285 }
3286 }
3287
3288 pub fn drain_events(&mut self) {
3290 while let Some(event) = self.session.try_next_event() {
3291 if self.handle_event_sync(event).is_err() {
3292 break;
3293 }
3294 }
3295 }
3296
3297 pub async fn next_event(&mut self) -> Option<SessionEvent> {
3299 self.session.next_event().await
3300 }
3301
3302 pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
3303 self.handle_event_sync(event)
3304 }
3305
3306 fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
3307 match event {
3308 SessionEvent::Welcome {
3309 session_id,
3310 entity_id,
3311 snapshot,
3312 } => {
3313 let resumed = self.state.connected;
3314 self.state
3315 .restore_from_welcome(session_id, entity_id, &snapshot);
3316 if resumed {
3317 self.state.push_log(format!(
3318 "Session restored — session {session_id}, entity {entity_id}"
3319 ));
3320 }
3321 }
3322 SessionEvent::ContentUpdated { snapshot } => {
3323 self.state
3324 .apply_snapshot_fields(&snapshot, self.state.entity_id);
3325 self.state.push_log(format!(
3326 "World updated (content rev {})",
3327 snapshot.content_rev
3328 ));
3329 }
3330 SessionEvent::Tick(delta) => {
3331 self.state.apply_tick_fields(&delta, self.state.entity_id);
3332 self.state.ticks_received += 1;
3333 }
3334 SessionEvent::IntentAck {
3335 entity_id,
3336 seq,
3337 tick,
3338 } => {
3339 crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
3340 if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
3341 if *craft_seq == seq {
3342 let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
3343 if batches > 1 {
3344 self.state.push_log(format!("Crafting {label} ×{batches}…"));
3345 } else {
3346 self.state.push_log(format!("Crafting {label}…"));
3347 }
3348 }
3349 }
3350 if self
3351 .state
3352 .pending_worker_job_ack
3353 .as_ref()
3354 .is_some_and(|p| p.seq == seq)
3355 {
3356 let pending = self.state.pending_worker_job_ack.take().unwrap();
3357 if pending.idle {
3358 self.state.push_log(format!(
3359 "Route cleared for {} — worker idle",
3360 pending.worker_label
3361 ));
3362 } else {
3363 self.state.push_log(format!(
3364 "Route saved for {} — {} stop(s), job loop active",
3365 pending.worker_label, pending.stop_count
3366 ));
3367 }
3368 if self
3369 .state
3370 .worker_route_editor
3371 .as_ref()
3372 .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
3373 {
3374 self.close_worker_route_editor();
3375 }
3376 }
3377 }
3378 SessionEvent::Chat(msg) => {
3379 let label = match msg.channel {
3380 flatland_protocol::ChatChannel::Nearby => "nearby",
3381 flatland_protocol::ChatChannel::WhisperStone => "whisper",
3382 };
3383 self.state
3384 .push_log(format!("[{label}] {}: {}", msg.from_name, msg.text));
3385 }
3386 SessionEvent::HarvestResult(result) => {
3387 self.state.clear_harvest_state();
3388 crate::harvest_trace!(
3389 entity_id = self.state.entity_id,
3390 node_id = %result.node_id,
3391 template = %result.item_template,
3392 quantity = result.quantity,
3393 client_tick = self.state.tick,
3394 "client applied harvest result"
3395 );
3396 self.state.push_log(format!(
3397 "Harvested {} x{} (on the ground — press P to pick up)",
3398 result.item_template, result.quantity
3399 ));
3400 }
3401 SessionEvent::CraftResult(result) => {
3402 for stack in &result.consumed {
3403 if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
3404 *qty = qty.saturating_sub(stack.quantity);
3405 if *qty == 0 {
3406 self.state.inventory.remove(&stack.template_id);
3407 }
3408 }
3409 }
3410 for stack in &result.outputs {
3411 *self
3412 .state
3413 .inventory
3414 .entry(stack.template_id.clone())
3415 .or_insert(0) += stack.quantity;
3416 }
3417 if let Some(output) = result.outputs.first() {
3418 if result.batch_total > 1 {
3419 self.state.push_log(format!(
3420 "Crafted {} x{} ({}/{})",
3421 output.template_id,
3422 output.quantity,
3423 result.batch_index,
3424 result.batch_total
3425 ));
3426 } else {
3427 self.state.push_log(format!(
3428 "Crafted {} x{}",
3429 output.template_id, output.quantity
3430 ));
3431 }
3432 } else {
3433 self.state
3434 .push_log(format!("Craft finished: {}", result.blueprint_id));
3435 }
3436 }
3437 SessionEvent::Death(notice) => {
3438 self.state.clear_harvest_state();
3439 self.state.push_log(notice.message.clone());
3440 self.state.push_log(format!(
3441 "Respawned at ({:.1}, {:.1})",
3442 notice.respawn_x, notice.respawn_y
3443 ));
3444 }
3445 SessionEvent::Interaction(notice) => {
3446 if notice.message.starts_with("Harvest failed:") {
3447 self.state.clear_harvest_state();
3448 }
3449 if notice.message.starts_with("Can't do that:") {
3450 self.state.pending_craft_ack = None;
3451 if let Some(pending) = self.state.pending_worker_job_ack.take() {
3452 if let Some(w) = self
3453 .state
3454 .hired_workers
3455 .iter_mut()
3456 .find(|w| w.instance_id == pending.worker_instance_id)
3457 {
3458 w.route = pending.prev_route;
3459 w.mode = pending.prev_mode;
3460 w.step_label = pending.prev_step_label;
3461 w.last_error = pending.prev_last_error;
3462 }
3463 let reason = notice
3464 .message
3465 .strip_prefix("Can't do that:")
3466 .unwrap_or(¬ice.message)
3467 .trim();
3468 self.state.push_log(format!(
3469 "Route save failed for {}: {reason}",
3470 pending.worker_label
3471 ));
3472 }
3473 }
3474 if notice.message.starts_with("Cast failed:") {
3475 self.state.cast_progress = None;
3476 }
3477 if notice.message.contains("slain the") {
3478 self.state.combat_target = None;
3479 self.state.combat_target_label = None;
3480 }
3481 self.state.apply_interaction_notice(¬ice);
3482 self.state.push_log(notice.message.clone());
3483 }
3484 SessionEvent::ShopOpened(catalog) => {
3485 self.state.apply_shop_catalog(catalog);
3486 }
3487 SessionEvent::NpcTalkOpened(opened) => {
3488 self.state.show_npc_verb_menu = false;
3489 if self.state.npc_verb_target.is_none() {
3490 self.state.npc_verb_target = Some(opened.npc_id.clone());
3491 }
3492 let label = opened.npc_label.clone();
3493 let banner = if !opened.trade_allowed {
3494 Some("Trade is unavailable right now.".to_string())
3495 } else {
3496 None
3497 };
3498 self.state.show_npc_chat = true;
3499 self.state.npc_chat = Some(NpcChatState {
3500 npc_id: opened.npc_id,
3501 npc_label: opened.npc_label,
3502 lines: if opened.greeting.is_empty() {
3503 vec![]
3504 } else {
3505 vec![format!("{label}: {}", opened.greeting)]
3506 },
3507 input: String::new(),
3508 pending: opened.greeting.is_empty(),
3509 talk_depth: opened.talk_depth,
3510 trade_allowed: opened.trade_allowed,
3511 banner,
3512 });
3513 }
3514 SessionEvent::NpcTalkPending(_) => {
3515 if let Some(chat) = self.state.npc_chat.as_mut() {
3516 chat.pending = true;
3517 }
3518 }
3519 SessionEvent::NpcTalkReply(reply) => {
3520 if let Some(chat) = self.state.npc_chat.as_mut() {
3521 if chat.npc_id == reply.npc_id {
3522 chat.pending = false;
3523 if reply.trade_disabled {
3524 chat.trade_allowed = false;
3525 chat.banner = Some("Trade is unavailable right now.".to_string());
3526 }
3527 if reply.wind_down {
3528 chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
3529 if chat.banner.is_none() {
3530 chat.banner =
3531 Some("They're wrapping up — keep it brief.".to_string());
3532 }
3533 }
3534 chat.lines
3535 .push(format!("{}: {}", chat.npc_label, reply.line));
3536 }
3537 }
3538 }
3539 SessionEvent::NpcTalkClosed(closed) => {
3540 if self
3541 .state
3542 .npc_chat
3543 .as_ref()
3544 .is_some_and(|c| c.npc_id == closed.npc_id)
3545 {
3546 self.state.show_npc_chat = false;
3547 self.state.npc_chat = None;
3548 }
3549 }
3550 SessionEvent::NpcTalkError(err) => {
3551 self.state.push_log(format!("Talk failed: {}", err.reason));
3552 if let Some(chat) = self.state.npc_chat.as_mut() {
3553 chat.pending = false;
3554 }
3555 }
3556 SessionEvent::UseResult(result) => {
3557 if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
3560 *qty = qty.saturating_sub(1);
3561 if *qty == 0 {
3562 self.state.inventory.remove(&result.template_id);
3563 }
3564 }
3565 }
3566 SessionEvent::QuestOffer(offer) => {
3567 self.state.pending_quest_offer = Some(offer.clone());
3568 self.state.show_quest_offer = true;
3569 self.state
3570 .push_log(format!("Quest offered: {}", offer.title));
3571 }
3572 SessionEvent::QuestAccepted(notice) => {
3573 self.state.show_quest_offer = false;
3574 self.state.pending_quest_offer = None;
3575 self.state.push_log(notice.message);
3576 }
3577 SessionEvent::QuestWithdrawn(notice) => {
3578 self.state.show_quest_menu = false;
3579 self.state.quest_withdraw_confirm = false;
3580 self.state.push_log(notice.message);
3581 }
3582 SessionEvent::QuestStepCompleted(notice) => {
3583 self.state.push_log(notice.message);
3584 }
3585 SessionEvent::QuestCompleted(notice) => {
3586 self.state.push_log(notice.message);
3587 }
3588 SessionEvent::Disconnected { reason } => {
3589 self.state.clear_harvest_state();
3590 self.state.connected = false;
3591 self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
3592 if let Some(r) = &self.state.disconnect_reason {
3593 self.state.push_log(format!("Disconnected: {r}"));
3594 } else {
3595 self.state.push_log("Disconnected from server");
3596 }
3597 }
3598 }
3599 Ok(())
3600 }
3601
3602 pub fn is_connected(&self) -> bool {
3603 self.state.connected
3604 }
3605
3606 pub fn close_overlays(&mut self) {
3607 self.state.show_stats = false;
3608 self.state.show_craft_menu = false;
3609 self.state.show_shop_menu = false;
3610 self.state.shop_catalog = None;
3611 self.state.show_npc_verb_menu = false;
3612 self.state.npc_verb_target = None;
3613 self.state.show_npc_chat = false;
3614 self.state.npc_chat = None;
3615 self.state.show_inventory_menu = false;
3616 self.state.show_loadout_menu = false;
3617 self.state.show_rotation_editor = false;
3618 self.state.rotation_editor.reset();
3619 self.state.show_rename_prompt = false;
3620 self.state.show_worker_rename = false;
3621 self.state.rename_buffer.clear();
3622 self.state.show_move_picker = false;
3623 self.state.move_picker = None;
3624 self.state.show_destroy_picker = false;
3625 self.state.destroy_confirm_pending = false;
3626 self.state.destroy_picker = None;
3627 self.state.show_quest_offer = false;
3628 self.state.pending_quest_offer = None;
3629 self.state.show_quest_menu = false;
3630 self.state.quest_withdraw_confirm = false;
3631 self.state.show_workers_menu = false;
3632 self.close_worker_give_picker();
3633 self.close_worker_give_target_picker();
3634 self.close_worker_take_picker();
3635 self.close_worker_teach_picker();
3636 self.state.worker_route_editor = None;
3637 }
3638
3639 pub fn back_on_esc(&mut self) -> bool {
3641 if self.state.show_rename_prompt {
3642 self.cancel_rename_prompt();
3643 return true;
3644 }
3645 if self.state.show_worker_rename {
3646 self.cancel_worker_rename();
3647 return true;
3648 }
3649 if self.state.show_destroy_picker {
3650 if self.state.destroy_confirm_pending {
3651 self.cancel_destroy_confirm();
3652 } else {
3653 self.close_destroy_picker();
3654 }
3655 return true;
3656 }
3657 if self.state.show_move_picker {
3658 self.close_move_picker();
3659 return true;
3660 }
3661 if self.state.show_rotation_editor {
3662 match self.state.rotation_editor.mode {
3663 RotationEditorMode::List => {
3664 self.state.show_rotation_editor = false;
3665 self.state.rotation_editor.reset();
3666 }
3667 RotationEditorMode::EditLabel => {
3668 self.state.rotation_editor.label_buffer.clear();
3669 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
3670 }
3671 RotationEditorMode::PickAbility => {
3672 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
3673 }
3674 RotationEditorMode::EditSequence => {
3675 self.state.rotation_editor.draft = None;
3676 self.state.rotation_editor.mode = RotationEditorMode::List;
3677 }
3678 }
3679 return true;
3680 }
3681 if self.state.show_inventory_menu {
3682 self.close_inventory_menu();
3683 return true;
3684 }
3685 if self.state.show_craft_menu {
3686 self.close_craft_menu();
3687 return true;
3688 }
3689 if self.state.show_quest_offer {
3690 self.quest_offer_decline();
3691 return true;
3692 }
3693 if self.state.show_shop_menu {
3694 self.back_from_shop_menu();
3695 return true;
3696 }
3697 if self.state.show_npc_chat {
3698 return false;
3700 }
3701 if self.state.show_npc_verb_menu {
3702 self.state.show_npc_verb_menu = false;
3703 self.state.npc_verb_target = None;
3704 return true;
3705 }
3706 if self.state.show_quest_menu {
3707 if self.state.quest_withdraw_confirm {
3708 self.state.quest_withdraw_confirm = false;
3709 } else {
3710 self.state.show_quest_menu = false;
3711 }
3712 return true;
3713 }
3714 if self.state.worker_route_editor.is_some() {
3715 if self.re_at_root_sheet() {
3717 self.close_worker_route_editor();
3718 } else {
3719 self.re_sheet_back();
3720 }
3721 return true;
3722 }
3723 if self.state.show_worker_give_picker {
3724 self.close_worker_give_picker();
3725 return true;
3726 }
3727 if self.state.show_worker_give_target_picker {
3728 self.close_worker_give_target_picker();
3729 return true;
3730 }
3731 if self.state.show_worker_take_picker {
3732 self.close_worker_take_picker();
3733 return true;
3734 }
3735 if self.state.show_worker_teach_picker {
3736 self.close_worker_teach_picker();
3737 return true;
3738 }
3739 if self.state.show_workers_menu {
3740 self.state.show_workers_menu = false;
3741 return true;
3742 }
3743 if self.state.show_loadout_menu {
3744 self.state.show_loadout_menu = false;
3745 return true;
3746 }
3747 if self.state.show_stats {
3748 self.state.show_stats = false;
3749 return true;
3750 }
3751 false
3752 }
3753
3754 pub fn toggle_stats(&mut self) {
3755 self.state.show_stats = !self.state.show_stats;
3756 if self.state.show_stats {
3757 self.state.show_craft_menu = false;
3758 self.state.show_shop_menu = false;
3759 self.state.shop_catalog = None;
3760 self.state.show_inventory_menu = false;
3761 }
3762 }
3763
3764 pub fn open_inventory_menu(&mut self) {
3765 self.state.show_inventory_menu = true;
3766 self.state.show_craft_menu = false;
3767 self.state.show_shop_menu = false;
3768 self.state.shop_catalog = None;
3769 self.state.show_stats = false;
3770 self.state.show_move_picker = false;
3771 self.state.move_picker = None;
3772 self.state.show_destroy_picker = false;
3773 self.state.destroy_confirm_pending = false;
3774 self.state.destroy_picker = None;
3775 self.state.show_rename_prompt = false;
3776 self.state.rename_buffer.clear();
3777 self.state.clamp_inventory_indices();
3778 }
3779
3780 pub fn close_inventory_menu(&mut self) {
3781 self.state.show_inventory_menu = false;
3782 self.state.show_move_picker = false;
3783 self.state.move_picker = None;
3784 self.state.show_destroy_picker = false;
3785 self.state.destroy_confirm_pending = false;
3786 self.state.destroy_picker = None;
3787 self.state.show_rename_prompt = false;
3788 self.state.rename_buffer.clear();
3789 }
3790
3791 pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
3792 let Some(row) = self.state.inventory_selected_row() else {
3793 anyhow::bail!("inventory empty");
3794 };
3795 if !self.state.row_is_renameable_container(&row) {
3796 anyhow::bail!("only storage containers can be renamed");
3797 }
3798 let current = row
3799 .stack
3800 .display_name
3801 .clone()
3802 .unwrap_or_else(|| row.stack.template_id.clone());
3803 self.state.rename_buffer = current;
3804 self.state.show_rename_prompt = true;
3805 self.state.show_worker_rename = false;
3806 self.state.show_move_picker = false;
3807 self.state.show_destroy_picker = false;
3808 self.state.destroy_confirm_pending = false;
3809 Ok(())
3810 }
3811
3812 pub fn cancel_rename_prompt(&mut self) {
3813 self.state.show_rename_prompt = false;
3814 self.state.rename_buffer.clear();
3815 }
3816
3817 pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
3818 let name = self.state.rename_buffer.trim().to_string();
3819 if name.is_empty() {
3820 anyhow::bail!("name cannot be empty");
3821 }
3822 let Some(row) = self.state.inventory_selected_row() else {
3823 anyhow::bail!("inventory empty");
3824 };
3825 let Some(instance_id) = row.stack.item_instance_id else {
3826 anyhow::bail!("item has no instance id");
3827 };
3828 self.seq += 1;
3829 self.session
3830 .submit_intent(Intent::RenameContainer {
3831 entity_id: self.state.entity_id,
3832 item_instance_id: instance_id,
3833 location: row.from.clone(),
3834 name,
3835 seq: self.seq,
3836 })
3837 .await?;
3838 self.state.intents_sent += 1;
3839 self.state.show_rename_prompt = false;
3840 self.state.rename_buffer.clear();
3841 Ok(())
3842 }
3843
3844 pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
3845 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
3846 anyhow::bail!("no worker selected");
3847 };
3848 self.state.rename_buffer = worker.label.clone();
3849 self.state.show_worker_rename = true;
3850 self.state.show_rename_prompt = false;
3851 Ok(())
3852 }
3853
3854 pub fn cancel_worker_rename(&mut self) {
3855 self.state.show_worker_rename = false;
3856 self.state.rename_buffer.clear();
3857 }
3858
3859 pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
3860 let name = self.state.rename_buffer.trim().to_string();
3861 if name.is_empty() {
3862 anyhow::bail!("name cannot be empty");
3863 }
3864 if name.chars().count() > 32 {
3865 anyhow::bail!("name must be 1–32 characters");
3866 }
3867 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
3868 anyhow::bail!("no worker selected");
3869 };
3870 let worker_instance_id = worker.instance_id.clone();
3871 self.seq += 1;
3872 self.session
3873 .submit_intent(Intent::RenameHiredWorker {
3874 entity_id: self.state.entity_id,
3875 worker_instance_id: worker_instance_id.clone(),
3876 name: name.clone(),
3877 seq: self.seq,
3878 })
3879 .await?;
3880 self.state.intents_sent += 1;
3881 if let Some(w) = self
3882 .state
3883 .hired_workers
3884 .iter_mut()
3885 .find(|w| w.instance_id == worker_instance_id)
3886 {
3887 w.label = name.clone();
3888 }
3889 if let Some(ed) = self.state.worker_route_editor.as_mut() {
3890 if ed.worker_instance_id == worker_instance_id {
3891 ed.worker_label = name.clone();
3892 }
3893 }
3894 self.state.show_worker_rename = false;
3895 self.state.rename_buffer.clear();
3896 self.state.push_log(format!("Renamed worker to \"{name}\""));
3897 Ok(())
3898 }
3899
3900 pub fn toggle_inventory_menu(&mut self) {
3901 if self.state.show_inventory_menu {
3902 self.close_inventory_menu();
3903 } else {
3904 self.open_inventory_menu();
3905 }
3906 }
3907
3908 pub fn inventory_menu_move(&mut self, delta: i32) {
3910 if self.state.show_move_picker {
3911 let n = self
3912 .state
3913 .move_picker
3914 .as_ref()
3915 .map(|p| p.options.len())
3916 .unwrap_or(0);
3917 if n == 0 {
3918 return;
3919 }
3920 let idx = self.state.move_picker_index as i32;
3921 self.state.move_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
3922 self.state.clamp_move_picker_quantity();
3923 return;
3924 }
3925 let n = self.state.inventory_selectable_rows().len();
3926 if n == 0 {
3927 return;
3928 }
3929 let idx = self.state.inventory_menu_index as i32;
3930 self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
3931 }
3932
3933 pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
3938 if self.state.show_destroy_picker {
3939 if self.state.destroy_confirm_pending {
3940 return self.confirm_destroy_item().await;
3941 }
3942 return self.request_destroy_confirm();
3943 }
3944 if self.state.show_move_picker {
3945 return self.confirm_move_picker().await;
3946 }
3947 let Some(row) = self.state.inventory_selected_row() else {
3948 anyhow::bail!("inventory empty");
3949 };
3950 if row.is_equip_shell {
3951 let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
3952 anyhow::bail!("not a worn item");
3953 };
3954 return self.equip_worn(slot, None).await;
3955 }
3956 if row.is_chest_shell {
3957 return self.open_chest_pickup_picker();
3958 }
3959 let template_id = row.stack.template_id.clone();
3960 let instance_id = row.stack.item_instance_id;
3961 let category = self.state.inventory_item_category(&template_id);
3962 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
3963
3964 if category == Some("weapon") {
3965 return self.equip_mainhand(Some(template_id)).await;
3966 }
3967 if category == Some("lodging") && on_person {
3968 if let Some(inst) = instance_id {
3969 return self.place_container(inst).await;
3970 }
3971 }
3972 if (category == Some("container") || category == Some("armor")) && on_person {
3973 if let Some(inst) = instance_id {
3974 let world_placeable = row.stack.world_placeable == Some(true)
3975 || template_id.contains("chest");
3976 if world_placeable {
3977 return self.place_container(inst).await;
3978 }
3979 if let Some(slot) = guess_body_slot(&template_id) {
3983 return self.equip_worn(slot, Some(inst)).await;
3984 }
3985 }
3986 }
3987 self.open_move_picker()
3991 }
3992
3993 pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
3995 let Some(row) = self.state.inventory_selected_row() else {
3996 anyhow::bail!("inventory empty");
3997 };
3998 if row.from != flatland_protocol::InventoryLocation::Root {
3999 anyhow::bail!("select a consumable on your person");
4000 }
4001 let category = self
4002 .state
4003 .inventory_item_category(&row.stack.template_id);
4004 if category != Some("consumable") {
4005 anyhow::bail!("selected item is not consumable");
4006 }
4007 self.use_item(&row.stack.template_id).await
4008 }
4009
4010 pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
4014 let Some(row) = self.state.inventory_selected_row() else {
4015 anyhow::bail!("inventory empty");
4016 };
4017 if row.is_equip_shell {
4018 anyhow::bail!("this is a worn bag — press Enter to unequip it");
4019 }
4020 if row.is_chest_shell {
4021 return self.open_chest_pickup_picker();
4022 }
4023 let Some(instance_id) = row.stack.item_instance_id else {
4024 anyhow::bail!("item has no instance id");
4025 };
4026 let mut options = self.state.move_destinations_for(
4027 &row.from,
4028 row.from_parent_instance_id,
4029 row.stack.item_instance_id,
4030 &row.stack.template_id,
4031 );
4032 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
4033 let category = self.state.inventory_item_category(&row.stack.template_id);
4034 if on_person && category == Some("consumable") {
4035 options.insert(
4036 0,
4037 MoveOption {
4038 label: "Use (eat / drink)".into(),
4039 kind: MoveOptionKind::Use,
4040 },
4041 );
4042 }
4043 let item_label = row
4044 .stack
4045 .display_name
4046 .clone()
4047 .unwrap_or_else(|| row.stack.template_id.clone());
4048 let initial_qty = if row.stack.quantity > 1 { 1 } else { row.stack.quantity };
4051 self.state.move_picker = Some(MovePicker {
4052 item_instance_id: instance_id,
4053 from: row.from,
4054 item_label,
4055 template_id: row.stack.template_id.clone(),
4056 stack_quantity: row.stack.quantity,
4057 quantity: initial_qty.max(1),
4058 options,
4059 });
4060 self.state.move_picker_index = 0;
4061 self.state.show_move_picker = true;
4062 self.state.show_destroy_picker = false;
4063 self.state.destroy_confirm_pending = false;
4064 self.state.destroy_picker = None;
4065 self.state.clamp_move_picker_quantity();
4066 Ok(())
4067 }
4068
4069 pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
4071 let Some(row) = self.state.inventory_selected_row() else {
4072 anyhow::bail!("inventory empty");
4073 };
4074 if !row.is_chest_shell {
4075 anyhow::bail!("not a placed chest");
4076 }
4077 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
4078 anyhow::bail!("not a placed chest");
4079 };
4080 let Some(instance_id) = row.stack.item_instance_id else {
4081 anyhow::bail!("chest has no instance id");
4082 };
4083 let chest = self
4084 .state
4085 .placed_containers
4086 .iter()
4087 .find(|c| c.id == *container_id)
4088 .cloned()
4089 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
4090 let (px, py) = self.state.player_position();
4091 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
4092 anyhow::bail!("too far from {}", chest.display_name);
4093 }
4094 if chest.locked && !chest.accessible {
4095 anyhow::bail!(
4096 "need the matching key for {} before picking it up",
4097 chest.display_name
4098 );
4099 }
4100 let options = self.state.chest_pickup_destinations(container_id);
4101 let item_label = row
4102 .stack
4103 .display_name
4104 .clone()
4105 .unwrap_or_else(|| row.stack.template_id.clone());
4106 self.state.move_picker = Some(MovePicker {
4107 item_instance_id: instance_id,
4108 from: row.from.clone(),
4109 item_label,
4110 template_id: row.stack.template_id.clone(),
4111 stack_quantity: 1,
4112 quantity: 1,
4113 options,
4114 });
4115 self.state.move_picker_index = 0;
4116 self.state.show_move_picker = true;
4117 self.state.show_destroy_picker = false;
4118 self.state.destroy_confirm_pending = false;
4119 self.state.destroy_picker = None;
4120 Ok(())
4121 }
4122
4123 pub fn close_move_picker(&mut self) {
4124 self.state.show_move_picker = false;
4125 self.state.move_picker = None;
4126 }
4127
4128 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
4129 self.state.move_picker_adjust_quantity(delta);
4130 }
4131
4132 pub fn move_picker_set_quantity_max(&mut self) {
4133 self.state.move_picker_set_quantity_max();
4134 }
4135
4136 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
4137 self.state.destroy_picker_adjust_quantity(delta);
4138 }
4139
4140 pub fn destroy_picker_set_quantity_max(&mut self) {
4141 self.state.destroy_picker_set_quantity_max();
4142 }
4143
4144 async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
4145 let Some(picker) = self.state.move_picker.clone() else {
4146 self.close_move_picker();
4147 return Ok(());
4148 };
4149 let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
4150 self.close_move_picker();
4151 return Ok(());
4152 };
4153 match option.kind {
4154 MoveOptionKind::Cancel => {
4155 self.close_move_picker();
4156 }
4157 MoveOptionKind::Use => {
4158 self.close_move_picker();
4159 self.use_item(&picker.template_id).await?;
4160 }
4161 MoveOptionKind::Drop => {
4162 self.close_move_picker();
4163 if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
4164 if self.state.key_drop_blocked(&stack) {
4165 anyhow::bail!("cannot drop the key while its chest is locked");
4166 }
4167 }
4168 self.drop_item(picker.item_instance_id, picker.from).await?;
4169 self.state
4170 .push_log(format!("Dropped {}", picker.item_label));
4171 }
4172 MoveOptionKind::PickupPlaced {
4173 container_id,
4174 nest_location,
4175 nest_parent_instance_id,
4176 } => {
4177 self.close_move_picker();
4178 self.pickup_container(container_id.clone()).await?;
4179 let nest_into_bag = nest_parent_instance_id.is_some()
4180 || !matches!(
4181 nest_location,
4182 flatland_protocol::InventoryLocation::Root
4183 );
4184 if nest_into_bag {
4185 self.move_item(
4186 picker.item_instance_id,
4187 flatland_protocol::InventoryLocation::Root,
4188 nest_location,
4189 nest_parent_instance_id,
4190 None,
4191 )
4192 .await?;
4193 self.state
4194 .push_log(format!("Picked up {} into bag", picker.item_label));
4195 } else {
4196 self.state
4197 .push_log(format!("Picked up {}", picker.item_label));
4198 }
4199 }
4200 MoveOptionKind::Move {
4201 location,
4202 parent_instance_id,
4203 } => {
4204 self.close_move_picker();
4205 let qty = if picker.quantity >= picker.stack_quantity {
4206 None
4207 } else {
4208 Some(picker.quantity)
4209 };
4210 self.move_item(
4211 picker.item_instance_id,
4212 picker.from,
4213 location,
4214 parent_instance_id,
4215 qty,
4216 )
4217 .await?;
4218 let moved = qty.unwrap_or(picker.stack_quantity);
4219 if moved >= picker.stack_quantity {
4220 self.state.push_log(format!("Moved {}", picker.item_label));
4221 } else {
4222 self.state.push_log(format!(
4223 "Moved {} ×{} of {}",
4224 picker.item_label, moved, picker.stack_quantity
4225 ));
4226 }
4227 }
4228 }
4229 Ok(())
4230 }
4231
4232 pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
4234 let Some(row) = self.state.inventory_selected_row() else {
4235 anyhow::bail!("inventory empty");
4236 };
4237 if row.is_equip_shell {
4238 anyhow::bail!("unequip the bag first (Enter), then drop from your person");
4239 }
4240 if row.is_chest_shell {
4241 anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
4242 }
4243 let Some(inst) = row.stack.item_instance_id else {
4244 anyhow::bail!("item has no instance id");
4245 };
4246 if self.state.key_drop_blocked(&row.stack) {
4247 anyhow::bail!("cannot drop the key while its chest is locked");
4248 }
4249 let label = row
4250 .stack
4251 .display_name
4252 .clone()
4253 .unwrap_or_else(|| row.stack.template_id.clone());
4254 self.drop_item(inst, row.from).await?;
4255 self.state.push_log(format!("Dropped {label}"));
4256 Ok(())
4257 }
4258
4259 pub async fn drop_item(
4260 &mut self,
4261 item_instance_id: uuid::Uuid,
4262 from: flatland_protocol::InventoryLocation,
4263 ) -> anyhow::Result<()> {
4264 self.seq += 1;
4265 self.session
4266 .submit_intent(Intent::DropItem {
4267 entity_id: self.state.entity_id,
4268 item_instance_id,
4269 from,
4270 seq: self.seq,
4271 })
4272 .await?;
4273 self.state.intents_sent += 1;
4274 Ok(())
4275 }
4276
4277 pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
4279 let Some(row) = self.state.inventory_selected_row() else {
4280 anyhow::bail!("inventory empty");
4281 };
4282 if row.is_equip_shell {
4283 anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
4284 }
4285 if row.is_chest_shell {
4286 anyhow::bail!("can't destroy a placed chest from the inventory list");
4287 }
4288 let Some(instance_id) = row.stack.item_instance_id else {
4289 anyhow::bail!("item has no instance id");
4290 };
4291 if self.state.key_drop_blocked(&row.stack) {
4292 anyhow::bail!("cannot destroy the key while its chest is locked");
4293 }
4294 let item_label = row
4295 .stack
4296 .display_name
4297 .clone()
4298 .unwrap_or_else(|| row.stack.template_id.clone());
4299 self.state.destroy_picker = Some(DestroyPicker {
4300 item_instance_id: instance_id,
4301 from: row.from,
4302 item_label,
4303 stack_quantity: row.stack.quantity,
4304 quantity: row.stack.quantity,
4305 });
4306 self.state.destroy_confirm_pending = false;
4307 self.state.show_destroy_picker = true;
4308 self.state.show_move_picker = false;
4309 self.state.move_picker = None;
4310 Ok(())
4311 }
4312
4313 pub fn close_destroy_picker(&mut self) {
4314 self.state.show_destroy_picker = false;
4315 self.state.destroy_confirm_pending = false;
4316 self.state.destroy_picker = None;
4317 }
4318
4319 pub fn cancel_destroy_confirm(&mut self) {
4320 self.state.destroy_confirm_pending = false;
4321 }
4322
4323 pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
4324 if self.state.destroy_picker.is_none() {
4325 self.close_destroy_picker();
4326 return Ok(());
4327 }
4328 self.state.destroy_confirm_pending = true;
4329 Ok(())
4330 }
4331
4332 pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
4333 let Some(picker) = self.state.destroy_picker.clone() else {
4334 self.close_destroy_picker();
4335 return Ok(());
4336 };
4337 let qty = if picker.quantity >= picker.stack_quantity {
4338 None
4339 } else {
4340 Some(picker.quantity)
4341 };
4342 self.destroy_item(picker.item_instance_id, picker.from, qty)
4343 .await?;
4344 let destroyed = qty.unwrap_or(picker.stack_quantity);
4345 if destroyed >= picker.stack_quantity {
4346 self.state
4347 .push_log(format!("Destroyed {}", picker.item_label));
4348 } else {
4349 self.state.push_log(format!(
4350 "Destroyed {} ×{} of {}",
4351 picker.item_label, destroyed, picker.stack_quantity
4352 ));
4353 }
4354 self.close_destroy_picker();
4355 Ok(())
4356 }
4357
4358 pub async fn destroy_item(
4359 &mut self,
4360 item_instance_id: uuid::Uuid,
4361 from: flatland_protocol::InventoryLocation,
4362 quantity: Option<u32>,
4363 ) -> anyhow::Result<()> {
4364 self.seq += 1;
4365 self.session
4366 .submit_intent(Intent::DestroyItem {
4367 entity_id: self.state.entity_id,
4368 item_instance_id,
4369 from,
4370 quantity,
4371 seq: self.seq,
4372 })
4373 .await?;
4374 self.state.intents_sent += 1;
4375 Ok(())
4376 }
4377
4378 pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
4380 if let Some(row) = self.state.inventory_selected_row() {
4381 if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
4382 return self.toggle_placed_chest_lock(container_id).await;
4383 }
4384 }
4385 self.toggle_nearby_chest_lock().await
4386 }
4387
4388 pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
4389 let chest = self
4390 .state
4391 .placed_containers
4392 .iter()
4393 .find(|c| c.id == container_id)
4394 .cloned()
4395 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
4396 let (px, py) = self.state.player_position();
4397 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
4398 anyhow::bail!("too far from {}", chest.display_name);
4399 }
4400 if !chest.accessible && chest.locked {
4401 anyhow::bail!(
4402 "need the matching key for {} (each crafted chest has its own key)",
4403 chest.display_name
4404 );
4405 }
4406 let lock = !chest.locked;
4407 self.set_container_locked(
4408 flatland_protocol::InventoryLocation::Placed {
4409 container_id: chest.id.clone(),
4410 },
4411 lock,
4412 )
4413 .await?;
4414 self.state.push_log(if lock {
4415 format!("Locked {}", chest.display_name)
4416 } else {
4417 format!("Unlocked {}", chest.display_name)
4418 });
4419 Ok(())
4420 }
4421
4422 pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
4424 let chest = self
4425 .state
4426 .nearest_placed_container(CONTAINER_RANGE_M)
4427 .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
4428 self.toggle_placed_chest_lock(&chest.id).await
4429 }
4430
4431 pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
4432 self.equip_mainhand(None).await
4433 }
4434
4435 pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
4436 let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
4437 for slot in slots {
4438 self.equip_worn(slot, None).await?;
4439 }
4440 Ok(())
4441 }
4442
4443 pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
4444 let (px, py) = self.state.player_position();
4445 let nearest = self
4446 .state
4447 .placed_containers
4448 .iter()
4449 .min_by(|a, b| {
4450 let da = (a.x - px).hypot(a.y - py);
4451 let db = (b.x - px).hypot(b.y - py);
4452 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
4453 })
4454 .cloned();
4455 let Some(chest) = nearest else {
4456 anyhow::bail!("no chest nearby");
4457 };
4458 if (chest.x - px).hypot(chest.y - py) > 2.0 {
4459 anyhow::bail!("too far from chest");
4460 }
4461 self.pickup_container(chest.id).await
4462 }
4463
4464 pub async fn equip_worn(
4465 &mut self,
4466 slot: BodySlot,
4467 instance_id: Option<uuid::Uuid>,
4468 ) -> anyhow::Result<()> {
4469 self.seq += 1;
4470 self.session
4471 .submit_intent(Intent::EquipWorn {
4472 entity_id: self.state.entity_id,
4473 slot,
4474 instance_id,
4475 seq: self.seq,
4476 })
4477 .await?;
4478 self.state.intents_sent += 1;
4479 Ok(())
4480 }
4481
4482 pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
4483 self.seq += 1;
4484 self.session
4485 .submit_intent(Intent::PlaceContainer {
4486 entity_id: self.state.entity_id,
4487 item_instance_id,
4488 seq: self.seq,
4489 })
4490 .await?;
4491 self.state.intents_sent += 1;
4492 Ok(())
4493 }
4494
4495 pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
4496 self.seq += 1;
4497 self.session
4498 .submit_intent(Intent::PickupContainer {
4499 entity_id: self.state.entity_id,
4500 container_id,
4501 seq: self.seq,
4502 })
4503 .await?;
4504 self.state.intents_sent += 1;
4505 Ok(())
4506 }
4507
4508 pub async fn move_item(
4509 &mut self,
4510 item_instance_id: uuid::Uuid,
4511 from: flatland_protocol::InventoryLocation,
4512 to: flatland_protocol::InventoryLocation,
4513 to_parent_instance_id: Option<uuid::Uuid>,
4514 quantity: Option<u32>,
4515 ) -> anyhow::Result<()> {
4516 self.seq += 1;
4517 self.session
4518 .submit_intent(Intent::MoveItem {
4519 entity_id: self.state.entity_id,
4520 item_instance_id,
4521 from,
4522 to,
4523 to_parent_instance_id,
4524 quantity,
4525 seq: self.seq,
4526 })
4527 .await?;
4528 self.state.intents_sent += 1;
4529 Ok(())
4530 }
4531
4532 pub async fn set_container_locked(
4533 &mut self,
4534 location: flatland_protocol::InventoryLocation,
4535 locked: bool,
4536 ) -> anyhow::Result<()> {
4537 self.seq += 1;
4538 self.session
4539 .submit_intent(Intent::SetContainerLocked {
4540 entity_id: self.state.entity_id,
4541 location,
4542 locked,
4543 seq: self.seq,
4544 })
4545 .await?;
4546 self.state.intents_sent += 1;
4547 Ok(())
4548 }
4549
4550 pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
4551 if !self.state.is_alive() {
4552 anyhow::bail!("you are dead");
4553 }
4554 self.seq += 1;
4555 self.session
4556 .submit_intent(Intent::Use {
4557 entity_id: self.state.entity_id,
4558 template_id: template_id.to_string(),
4559 seq: self.seq,
4560 })
4561 .await?;
4562 self.state.intents_sent += 1;
4563 Ok(())
4564 }
4565
4566 pub fn open_craft_menu(&mut self) {
4567 self.state.show_craft_menu = true;
4568 self.state.show_shop_menu = false;
4569 self.state.shop_catalog = None;
4570 self.state.show_stats = false;
4571 self.state.show_inventory_menu = false;
4572 if self.state.blueprints.is_empty() {
4573 self.state.craft_menu_index = 0;
4574 self.state.craft_batch_quantity = 1;
4575 return;
4576 }
4577 self.state.craft_menu_index = self
4578 .state
4579 .craft_menu_index
4580 .min(self.state.blueprints.len() - 1);
4581 if let Some(idx) = self
4582 .state
4583 .blueprints
4584 .iter()
4585 .position(|bp| self.state.can_craft_blueprint(bp))
4586 {
4587 self.state.craft_menu_index = idx;
4588 }
4589 self.state.clamp_craft_batch_quantity();
4590 }
4591
4592 pub fn close_craft_menu(&mut self) {
4593 self.state.show_craft_menu = false;
4594 }
4595
4596 pub fn toggle_keychain_menu(&mut self) {
4597 if self.state.show_keychain_menu {
4598 self.close_keychain_menu();
4599 } else {
4600 self.state.show_keychain_menu = true;
4601 self.state.show_craft_menu = false;
4602 self.state.show_shop_menu = false;
4603 self.state.show_inventory_menu = false;
4604 let n = self.state.keychain_entries().len();
4605 if n == 0 {
4606 self.state.keychain_menu_index = 0;
4607 } else {
4608 self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
4609 }
4610 }
4611 }
4612
4613 pub fn close_keychain_menu(&mut self) {
4614 self.state.show_keychain_menu = false;
4615 }
4616
4617 pub fn keychain_menu_move(&mut self, delta: i32) {
4618 let n = self.state.keychain_entries().len();
4619 if n == 0 {
4620 self.state.keychain_menu_index = 0;
4621 return;
4622 }
4623 let idx = self.state.keychain_menu_index as i32 + delta;
4624 self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
4625 }
4626
4627 pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
4628 if !self.state.is_alive() {
4629 anyhow::bail!("you are dead");
4630 }
4631 let entries = self.state.keychain_entries();
4632 let Some(entry) = entries.get(self.state.keychain_menu_index) else {
4633 anyhow::bail!("nothing selected");
4634 };
4635 let Some(instance_id) = entry.stack.item_instance_id else {
4636 anyhow::bail!("key has no instance id");
4637 };
4638 if entry.stowed {
4639 self.move_item(
4640 instance_id,
4641 flatland_protocol::InventoryLocation::Keychain,
4642 flatland_protocol::InventoryLocation::Root,
4643 None,
4644 Some(1),
4645 )
4646 .await
4647 } else {
4648 self.move_item(
4649 instance_id,
4650 flatland_protocol::InventoryLocation::Root,
4651 flatland_protocol::InventoryLocation::Keychain,
4652 None,
4653 Some(1),
4654 )
4655 .await
4656 }
4657 }
4658
4659 pub fn close_shop_menu(&mut self) {
4660 self.state.show_shop_menu = false;
4661 self.state.shop_catalog = None;
4662 self.state.clear_shop_trade_log();
4663 }
4664
4665 pub fn back_from_shop_menu(&mut self) {
4667 let return_to_verbs = self.state.npc_verb_target.is_some();
4668 self.close_shop_menu();
4669 if return_to_verbs {
4670 self.state.show_npc_verb_menu = true;
4671 }
4672 }
4673
4674 pub fn shop_tab_toggle(&mut self) {
4675 self.state.shop_tab = match self.state.shop_tab {
4676 ShopTab::Buy => ShopTab::Sell,
4677 ShopTab::Sell => ShopTab::Buy,
4678 };
4679 self.state.shop_menu_index = 0;
4680 if self.state.shop_tab == ShopTab::Sell {
4681 self.state.shop_quantity_set_max();
4682 }
4683 self.state.clamp_shop_selection();
4684 }
4685
4686 pub fn shop_menu_move(&mut self, delta: i32) {
4687 self.state.shop_menu_move(delta);
4688 }
4689
4690 pub fn shop_quantity_adjust(&mut self, delta: i32) {
4691 self.state.shop_quantity_adjust(delta);
4692 }
4693
4694 pub fn shop_quantity_set_max(&mut self) {
4695 self.state.shop_quantity_set_max();
4696 }
4697
4698 pub fn toggle_quest_menu(&mut self) {
4699 self.state.show_quest_menu = !self.state.show_quest_menu;
4700 if self.state.show_quest_menu {
4701 self.state.quest_menu_index = 0;
4702 self.state.quest_withdraw_confirm = false;
4703 self.state.show_workers_menu = false;
4704 }
4705 }
4706
4707 pub fn toggle_workers_menu(&mut self) {
4708 self.state.show_workers_menu = !self.state.show_workers_menu;
4709 if self.state.show_workers_menu {
4710 self.state.workers_menu_index = 0;
4711 self.state.show_quest_menu = false;
4712 self.close_worker_give_picker();
4713 self.close_worker_give_target_picker();
4714 self.close_worker_take_picker();
4715 self.close_worker_teach_picker();
4716 self.cancel_worker_rename();
4717 } else {
4718 self.close_worker_give_picker();
4719 self.close_worker_give_target_picker();
4720 self.close_worker_take_picker();
4721 self.close_worker_teach_picker();
4722 self.cancel_worker_rename();
4723 }
4724 }
4725
4726 pub fn workers_menu_move(&mut self, delta: i32) {
4727 let n = self.state.hired_workers.len();
4728 if n == 0 {
4729 return;
4730 }
4731 let idx = self.state.workers_menu_index as i32;
4732 self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
4733 }
4734
4735 pub fn toggle_workers_menu_compact(&mut self) {
4736 self.state.workers_menu_compact = !self.state.workers_menu_compact;
4737 }
4738
4739 pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
4740 let Some(worker) = self
4741 .state
4742 .hired_workers
4743 .get(self.state.workers_menu_index)
4744 .cloned()
4745 else {
4746 anyhow::bail!("no worker selected");
4747 };
4748 self.seq += 1;
4749 self.session
4750 .submit_intent(Intent::DismissWorker {
4751 entity_id: self.state.entity_id,
4752 worker_instance_id: worker.instance_id.clone(),
4753 seq: self.seq,
4754 })
4755 .await?;
4756 self.state.intents_sent += 1;
4757 self.state
4758 .hired_workers
4759 .retain(|w| w.instance_id != worker.instance_id);
4760 if self.state.workers_menu_index >= self.state.hired_workers.len() {
4761 self.state.workers_menu_index = self
4762 .state
4763 .hired_workers
4764 .len()
4765 .saturating_sub(1);
4766 }
4767 self.state.push_log(format!("Dismissed {}", worker.label));
4768 Ok(())
4769 }
4770
4771 pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
4772 let Some(worker) = self
4773 .state
4774 .hired_workers
4775 .get(self.state.workers_menu_index)
4776 .cloned()
4777 else {
4778 anyhow::bail!("no worker selected");
4779 };
4780 let mode = match worker.mode {
4781 flatland_protocol::WorkerModeView::Companion => "job_loop",
4782 flatland_protocol::WorkerModeView::JobLoop => "idle",
4783 flatland_protocol::WorkerModeView::Idle => "companion",
4784 };
4785 self.seq += 1;
4786 self.session
4787 .submit_intent(Intent::SetWorkerMode {
4788 entity_id: self.state.entity_id,
4789 worker_instance_id: worker.instance_id,
4790 mode: mode.into(),
4791 seq: self.seq,
4792 })
4793 .await?;
4794 self.state.intents_sent += 1;
4795 Ok(())
4796 }
4797
4798 pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
4799 if self.state.hired_workers.is_empty() {
4800 return self.hire_worker_laborer().await;
4801 }
4802 self.workers_toggle_mode_selected().await
4803 }
4804
4805 pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
4808 let row = self
4809 .state
4810 .inventory_selected_row()
4811 .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
4812 .clone();
4813 if row.from != flatland_protocol::InventoryLocation::Root {
4814 anyhow::bail!("select a carried item to give");
4815 }
4816 let Some(instance_id) = row.stack.item_instance_id else {
4817 anyhow::bail!("that stack can't be given");
4818 };
4819 let options = self.nearby_worker_give_targets();
4820 if options.is_empty() {
4821 anyhow::bail!(
4822 "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
4823 );
4824 }
4825 let item_label = row
4826 .stack
4827 .display_name
4828 .as_deref()
4829 .unwrap_or(&row.stack.template_id)
4830 .to_string();
4831 self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
4832 item_instance_id: instance_id,
4833 item_label,
4834 quantity: None,
4835 options,
4836 });
4837 self.state.worker_give_target_picker_index = 0;
4838 self.state.show_worker_give_target_picker = true;
4839 self.state.show_inventory_menu = false;
4841 Ok(())
4842 }
4843
4844 pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
4846 let (px, py, _) = self.state.player_position_with_z();
4847 let mut options: Vec<WorkerGiveTargetOption> = self
4848 .state
4849 .hired_workers
4850 .iter()
4851 .filter_map(|w| {
4852 let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
4853 if dist > WORKER_GIVE_RANGE_M {
4854 return None;
4855 }
4856 Some(WorkerGiveTargetOption {
4857 instance_id: w.instance_id.clone(),
4858 label: w.label.clone(),
4859 distance_m: dist,
4860 })
4861 })
4862 .collect();
4863 options.sort_by(|a, b| {
4864 a.distance_m
4865 .partial_cmp(&b.distance_m)
4866 .unwrap_or(std::cmp::Ordering::Equal)
4867 });
4868 options
4869 }
4870
4871 pub fn close_worker_give_target_picker(&mut self) {
4872 self.state.show_worker_give_target_picker = false;
4873 self.state.worker_give_target_picker = None;
4874 self.state.worker_give_target_picker_index = 0;
4875 }
4876
4877 pub fn worker_give_target_picker_move(&mut self, delta: i32) {
4878 let Some(picker) = &self.state.worker_give_target_picker else {
4879 return;
4880 };
4881 let n = picker.options.len();
4882 if n == 0 {
4883 return;
4884 }
4885 let idx = self.state.worker_give_target_picker_index as i32;
4886 self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
4887 }
4888
4889 pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
4890 let Some(picker) = self.state.worker_give_target_picker.clone() else {
4891 anyhow::bail!("give target picker not open");
4892 };
4893 let Some(opt) = picker
4894 .options
4895 .get(self.state.worker_give_target_picker_index)
4896 .cloned()
4897 else {
4898 anyhow::bail!("no worker selected");
4899 };
4900 let Some(worker) = self
4901 .state
4902 .hired_workers
4903 .iter()
4904 .find(|w| w.instance_id == opt.instance_id)
4905 .cloned()
4906 else {
4907 self.close_worker_give_target_picker();
4908 anyhow::bail!("worker no longer hired");
4909 };
4910 self.give_item_to_worker(
4911 &worker.instance_id,
4912 &worker.label,
4913 worker.x,
4914 worker.y,
4915 picker.item_instance_id,
4916 &picker.item_label,
4917 picker.quantity,
4918 )
4919 .await?;
4920 self.close_worker_give_target_picker();
4921 Ok(())
4922 }
4923
4924 pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
4926 self.open_worker_give_target_picker()
4927 }
4928
4929 pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
4931 let Some(worker) = self
4932 .state
4933 .hired_workers
4934 .get(self.state.workers_menu_index)
4935 .cloned()
4936 else {
4937 anyhow::bail!("select a hired worker first");
4938 };
4939 let (px, py, _) = self.state.player_position_with_z();
4940 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
4941 if dist > WORKER_GIVE_RANGE_M {
4942 anyhow::bail!(
4943 "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
4944 worker.label
4945 );
4946 }
4947 let options = self.state.giveable_inventory_options();
4948 if options.is_empty() {
4949 anyhow::bail!("nothing in inventory to give");
4950 }
4951 self.state.worker_give_picker = Some(WorkerGivePicker {
4952 worker_instance_id: worker.instance_id,
4953 worker_label: worker.label,
4954 options,
4955 });
4956 self.state.worker_give_picker_index = 0;
4957 self.state.show_worker_give_picker = true;
4958 Ok(())
4959 }
4960
4961 pub fn close_worker_give_picker(&mut self) {
4962 self.state.show_worker_give_picker = false;
4963 self.state.worker_give_picker = None;
4964 self.state.worker_give_picker_index = 0;
4965 }
4966
4967 pub fn worker_give_picker_move(&mut self, delta: i32) {
4968 let Some(picker) = &self.state.worker_give_picker else {
4969 return;
4970 };
4971 let n = picker.options.len();
4972 if n == 0 {
4973 return;
4974 }
4975 let idx = self.state.worker_give_picker_index as i32;
4976 self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
4977 }
4978
4979 pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
4981 let Some(picker) = self.state.worker_give_picker.clone() else {
4982 anyhow::bail!("give picker not open");
4983 };
4984 let Some(opt) = picker.options.get(self.state.worker_give_picker_index).cloned() else {
4985 anyhow::bail!("no item selected");
4986 };
4987 let Some(worker) = self
4988 .state
4989 .hired_workers
4990 .iter()
4991 .find(|w| w.instance_id == picker.worker_instance_id)
4992 .cloned()
4993 else {
4994 self.close_worker_give_picker();
4995 anyhow::bail!("worker no longer hired");
4996 };
4997 self.give_item_to_worker(
4998 &worker.instance_id,
4999 &worker.label,
5000 worker.x,
5001 worker.y,
5002 opt.item_instance_id,
5003 &opt.label,
5004 None,
5005 )
5006 .await?;
5007 let options = self.state.giveable_inventory_options();
5009 if options.is_empty() {
5010 self.close_worker_give_picker();
5011 } else {
5012 self.state.worker_give_picker = Some(WorkerGivePicker {
5013 worker_instance_id: picker.worker_instance_id,
5014 worker_label: picker.worker_label,
5015 options,
5016 });
5017 if self.state.worker_give_picker_index
5018 >= self
5019 .state
5020 .worker_give_picker
5021 .as_ref()
5022 .map(|p| p.options.len())
5023 .unwrap_or(0)
5024 {
5025 self.state.worker_give_picker_index = self
5026 .state
5027 .worker_give_picker
5028 .as_ref()
5029 .map(|p| p.options.len().saturating_sub(1))
5030 .unwrap_or(0);
5031 }
5032 }
5033 Ok(())
5034 }
5035
5036 pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
5038 let Some(worker) = self
5039 .state
5040 .hired_workers
5041 .get(self.state.workers_menu_index)
5042 .cloned()
5043 else {
5044 anyhow::bail!("select a hired worker first");
5045 };
5046 let (px, py, _) = self.state.player_position_with_z();
5047 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
5048 if dist > WORKER_GIVE_RANGE_M {
5049 anyhow::bail!(
5050 "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
5051 worker.label
5052 );
5053 }
5054 let options = self.state.teachable_blueprint_options(&worker);
5055 if options.is_empty() {
5056 anyhow::bail!("no recipes you know that {} still needs", worker.label);
5057 }
5058 self.state.worker_teach_picker = Some(WorkerTeachPicker {
5059 worker_instance_id: worker.instance_id,
5060 worker_label: worker.label,
5061 worker_level: worker.level,
5062 options,
5063 });
5064 self.state.worker_teach_picker_index = 0;
5065 self.state.show_worker_teach_picker = true;
5066 Ok(())
5067 }
5068
5069 pub fn close_worker_teach_picker(&mut self) {
5070 self.state.show_worker_teach_picker = false;
5071 self.state.worker_teach_picker = None;
5072 self.state.worker_teach_picker_index = 0;
5073 }
5074
5075 pub fn worker_teach_picker_move(&mut self, delta: i32) {
5076 let Some(picker) = &self.state.worker_teach_picker else {
5077 return;
5078 };
5079 let n = picker.options.len();
5080 if n == 0 {
5081 return;
5082 }
5083 let idx = self.state.worker_teach_picker_index as i32;
5084 self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
5085 }
5086
5087 pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
5088 let Some(picker) = self.state.worker_teach_picker.clone() else {
5089 anyhow::bail!("teach picker not open");
5090 };
5091 let Some(opt) = picker.options.get(self.state.worker_teach_picker_index).cloned() else {
5092 anyhow::bail!("nothing selected");
5093 };
5094 if !opt.level_ok {
5095 anyhow::bail!(
5096 "{} needs level {} (is level {})",
5097 picker.worker_label,
5098 opt.min_level,
5099 opt.worker_level
5100 );
5101 }
5102 if !opt.can_afford {
5103 anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
5104 }
5105 let Some(worker) = self
5106 .state
5107 .hired_workers
5108 .iter()
5109 .find(|w| w.instance_id == picker.worker_instance_id)
5110 .cloned()
5111 else {
5112 anyhow::bail!("worker gone");
5113 };
5114 let (px, py, _) = self.state.player_position_with_z();
5115 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
5116 if dist > WORKER_GIVE_RANGE_M {
5117 anyhow::bail!("worker {} too far — stand next to them", worker.label);
5118 }
5119 self.seq += 1;
5120 self.session
5121 .submit_intent(Intent::TeachWorkerBlueprint {
5122 entity_id: self.state.entity_id,
5123 worker_instance_id: picker.worker_instance_id.clone(),
5124 blueprint_id: opt.blueprint_id.clone(),
5125 seq: self.seq,
5126 })
5127 .await?;
5128 self.state.intents_sent += 1;
5129 self.state.push_log(format!(
5130 "Teaching {} to {} ({} cp)",
5131 opt.label, picker.worker_label, opt.cost_copper
5132 ));
5133 self.close_worker_teach_picker();
5134 Ok(())
5135 }
5136
5137 async fn give_item_to_worker(
5138 &mut self,
5139 worker_instance_id: &str,
5140 worker_label: &str,
5141 worker_x: f32,
5142 worker_y: f32,
5143 item_instance_id: uuid::Uuid,
5144 item_label: &str,
5145 quantity: Option<u32>,
5146 ) -> anyhow::Result<()> {
5147 let (px, py, _) = self.state.player_position_with_z();
5148 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
5149 if dist > WORKER_GIVE_RANGE_M {
5150 anyhow::bail!("worker {worker_label} too far — stand next to them");
5151 }
5152 self.seq += 1;
5153 self.session
5154 .submit_intent(Intent::GiveWorkerItem {
5155 entity_id: self.state.entity_id,
5156 worker_instance_id: worker_instance_id.to_string(),
5157 item_instance_id,
5158 quantity,
5159 seq: self.seq,
5160 })
5161 .await?;
5162 self.state.intents_sent += 1;
5163 self.state
5164 .push_log(format!("Gave {item_label} to {worker_label}"));
5165 Ok(())
5166 }
5167
5168 pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
5170 let Some(worker) = self
5171 .state
5172 .hired_workers
5173 .get(self.state.workers_menu_index)
5174 .cloned()
5175 else {
5176 anyhow::bail!("select a hired worker first");
5177 };
5178 let (px, py, _) = self.state.player_position_with_z();
5179 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
5180 if dist > WORKER_GIVE_RANGE_M {
5181 anyhow::bail!(
5182 "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
5183 worker.label
5184 );
5185 }
5186 let options = Self::worker_inventory_options(&worker);
5187 if options.is_empty() {
5188 anyhow::bail!("{} isn't carrying anything", worker.label);
5189 }
5190 self.state.worker_take_picker = Some(WorkerTakePicker {
5191 worker_instance_id: worker.instance_id,
5192 worker_label: worker.label,
5193 options,
5194 });
5195 self.state.worker_take_picker_index = 0;
5196 self.state.show_worker_take_picker = true;
5197 Ok(())
5198 }
5199
5200 fn worker_inventory_options(
5201 worker: &flatland_protocol::HiredWorkerView,
5202 ) -> Vec<WorkerGiveOption> {
5203 worker
5204 .inventory
5205 .iter()
5206 .filter_map(|stack| {
5207 let item_instance_id = stack.item_instance_id?;
5208 let label = stack
5209 .display_name
5210 .clone()
5211 .unwrap_or_else(|| stack.template_id.clone());
5212 let label = if stack.quantity > 1 {
5213 format!("{label} ×{}", stack.quantity)
5214 } else {
5215 label
5216 };
5217 Some(WorkerGiveOption {
5218 item_instance_id,
5219 label,
5220 quantity: stack.quantity,
5221 template_id: stack.template_id.clone(),
5222 })
5223 })
5224 .collect()
5225 }
5226
5227 pub fn close_worker_take_picker(&mut self) {
5228 self.state.show_worker_take_picker = false;
5229 self.state.worker_take_picker = None;
5230 self.state.worker_take_picker_index = 0;
5231 }
5232
5233 pub fn worker_take_picker_move(&mut self, delta: i32) {
5234 let Some(picker) = &self.state.worker_take_picker else {
5235 return;
5236 };
5237 let n = picker.options.len();
5238 if n == 0 {
5239 return;
5240 }
5241 let idx = self.state.worker_take_picker_index as i32;
5242 self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
5243 }
5244
5245 pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
5246 let Some(picker) = self.state.worker_take_picker.clone() else {
5247 anyhow::bail!("take picker not open");
5248 };
5249 let Some(opt) = picker.options.get(self.state.worker_take_picker_index).cloned() else {
5250 anyhow::bail!("no item selected");
5251 };
5252 let Some(worker) = self
5253 .state
5254 .hired_workers
5255 .iter()
5256 .find(|w| w.instance_id == picker.worker_instance_id)
5257 .cloned()
5258 else {
5259 self.close_worker_take_picker();
5260 anyhow::bail!("worker no longer hired");
5261 };
5262 self.take_item_from_worker(
5263 &worker.instance_id,
5264 &worker.label,
5265 worker.x,
5266 worker.y,
5267 opt.item_instance_id,
5268 &opt.label,
5269 None,
5270 )
5271 .await?;
5272 if let Some(w) = self
5276 .state
5277 .hired_workers
5278 .iter()
5279 .find(|w| w.instance_id == picker.worker_instance_id)
5280 {
5281 let options = Self::worker_inventory_options(w)
5282 .into_iter()
5283 .filter(|o| o.item_instance_id != opt.item_instance_id)
5284 .collect::<Vec<_>>();
5285 if options.is_empty() {
5286 self.close_worker_take_picker();
5287 } else {
5288 self.state.worker_take_picker = Some(WorkerTakePicker {
5289 worker_instance_id: picker.worker_instance_id,
5290 worker_label: picker.worker_label,
5291 options,
5292 });
5293 self.state.worker_take_picker_index = self
5294 .state
5295 .worker_take_picker_index
5296 .min(
5297 self.state
5298 .worker_take_picker
5299 .as_ref()
5300 .map(|p| p.options.len().saturating_sub(1))
5301 .unwrap_or(0),
5302 );
5303 }
5304 } else {
5305 self.close_worker_take_picker();
5306 }
5307 Ok(())
5308 }
5309
5310 async fn take_item_from_worker(
5311 &mut self,
5312 worker_instance_id: &str,
5313 worker_label: &str,
5314 worker_x: f32,
5315 worker_y: f32,
5316 item_instance_id: uuid::Uuid,
5317 item_label: &str,
5318 quantity: Option<u32>,
5319 ) -> anyhow::Result<()> {
5320 let (px, py, _) = self.state.player_position_with_z();
5321 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
5322 if dist > WORKER_GIVE_RANGE_M {
5323 anyhow::bail!("worker {worker_label} too far — stand next to them");
5324 }
5325 self.seq += 1;
5326 self.session
5327 .submit_intent(Intent::TakeWorkerItem {
5328 entity_id: self.state.entity_id,
5329 worker_instance_id: worker_instance_id.to_string(),
5330 item_instance_id,
5331 quantity,
5332 seq: self.seq,
5333 })
5334 .await?;
5335 self.state.intents_sent += 1;
5336 self.state
5337 .push_log(format!("Took {item_label} from {worker_label}"));
5338 Ok(())
5339 }
5340
5341 pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
5342 if !self.state.has_worker_lodging() {
5343 anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
5344 }
5345 self.seq += 1;
5346 self.session
5347 .submit_intent(Intent::HireWorker {
5348 entity_id: self.state.entity_id,
5349 def_id: "worker_laborer".into(),
5350 wage_copper_per_interval: 8,
5351 lodging_container_id: None,
5352 job_yaml: None,
5353 seq: self.seq,
5354 })
5355 .await?;
5356 self.state.intents_sent += 1;
5357 Ok(())
5358 }
5359
5360 pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
5361 let Some(worker) = self
5362 .state
5363 .hired_workers
5364 .get(self.state.workers_menu_index)
5365 .cloned()
5366 else {
5367 anyhow::bail!("select a hired worker first");
5368 };
5369 let lodging = worker.lodging_container_id.clone().or_else(|| {
5370 crate::worker_route_editor::owned_lodging_container_ids(
5371 &self.state.placed_containers,
5372 self.state.character_id,
5373 )
5374 .into_iter()
5375 .next()
5376 .map(|(id, _)| id)
5377 });
5378 let label = worker.label.clone();
5379 let editor = if let Some(route) = &worker.route {
5380 crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
5381 worker.instance_id,
5382 worker.label,
5383 route,
5384 lodging,
5385 )
5386 } else {
5387 crate::worker_route_editor::WorkerRouteEditorState::new(
5388 worker.instance_id,
5389 worker.label,
5390 lodging,
5391 )
5392 };
5393 self.state.worker_route_editor = Some(editor);
5394 self.state.show_workers_menu = false;
5395 self.state.push_log(format!(
5396 "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
5397 ));
5398 Ok(())
5399 }
5400
5401 pub fn close_worker_route_editor(&mut self) {
5402 self.state.worker_route_editor = None;
5403 }
5404
5405 pub fn worker_route_editor_toggle_panel(&mut self) {
5406 if let Some(ed) = self.state.worker_route_editor.as_mut() {
5407 ed.toggle_panel_collapsed();
5408 }
5409 }
5410
5411 pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
5412 let n = {
5413 let Some(ed) = self.state.worker_route_editor.as_mut() else {
5414 return;
5415 };
5416 ed.append_waypoint(x, y, z);
5417 ed.stop_count()
5418 };
5419 self.state
5420 .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
5421 }
5422
5423 fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
5426 let (px, py, _) = self.state.player_position_with_z();
5427 crate::worker_route_editor::owned_container_candidates(
5428 &self.state.placed_containers,
5429 self.state.character_id,
5430 px,
5431 py,
5432 )
5433 }
5434
5435 fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
5436 let (px, py, _) = self.state.player_position_with_z();
5437 crate::worker_route_editor::node_candidates(&self.state.resource_nodes, px, py)
5438 }
5439
5440 fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
5441 let (px, py, _) = self.state.player_position_with_z();
5442 crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
5443 }
5444
5445 fn re_template_candidates(&self) -> Vec<String> {
5446 let mut extra = Vec::new();
5447 if let Some(ed) = self.state.worker_route_editor.as_ref() {
5448 for stop in &ed.stops {
5449 match stop {
5450 crate::worker_route_editor::WorkerRouteStop::DepositAt {
5451 filter: Some(filter),
5452 ..
5453 } => extra.extend(filter.iter().cloned()),
5454 crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
5455 extra.push(template.clone());
5456 }
5457 crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
5458 if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint) {
5459 extra.push(bp.output.clone());
5460 for input in &bp.inputs {
5461 extra.push(input.template_id.clone());
5462 }
5463 }
5464 }
5465 crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
5466 for it in items {
5467 extra.push(it.template.clone());
5468 }
5469 }
5470 _ => {}
5471 }
5472 }
5473 if let Some(worker) = self
5475 .state
5476 .hired_workers
5477 .iter()
5478 .find(|w| w.instance_id == ed.worker_instance_id)
5479 {
5480 for recipe in &worker.known_blueprint_ids {
5481 if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
5482 extra.push(bp.output.clone());
5483 }
5484 }
5485 }
5486 }
5487 crate::worker_route_editor::route_item_template_candidates(
5488 &self.state.placed_containers,
5489 self.state.character_id,
5490 &self.state.inventory,
5491 &self.state.blueprints,
5492 &self.state.resource_nodes,
5493 &extra,
5494 )
5495 }
5496
5497 fn re_blueprint_ids(&self) -> Vec<String> {
5498 let worker_known: Option<&[String]> = self
5499 .state
5500 .worker_route_editor
5501 .as_ref()
5502 .and_then(|ed| {
5503 self.state
5504 .hired_workers
5505 .iter()
5506 .find(|w| w.instance_id == ed.worker_instance_id)
5507 })
5508 .map(|w| w.known_blueprint_ids.as_slice());
5509 crate::worker_route_editor::worker_craft_blueprint_ids(
5510 &self.state.blueprints,
5511 worker_known,
5512 )
5513 }
5514
5515 fn re_bed_candidates(&self) -> Vec<(String, String)> {
5516 crate::worker_route_editor::owned_lodging_container_ids(
5517 &self.state.placed_containers,
5518 self.state.character_id,
5519 )
5520 }
5521
5522 fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
5523 self.state
5524 .placed_containers
5525 .iter()
5526 .find(|c| c.id == container_id)
5527 .map(|c| c.contents.clone())
5528 .unwrap_or_default()
5529 }
5530
5531 pub fn re_sheet_row_count(&self) -> usize {
5535 use crate::worker_route_editor::RouteEditorSheet as S;
5536 let Some(ed) = self.state.worker_route_editor.as_ref() else {
5537 return 0;
5538 };
5539 match &ed.sheet {
5540 S::Stops => ed.stops.len(),
5541 S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
5542 S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
5543 S::WaypointMapPick => 0,
5544 S::HarvestPicker { .. } => self.re_node_candidates().len(),
5545 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
5546 self.re_container_candidates().len()
5547 }
5548 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(),
5553 S::WaitEntry { .. } => 1,
5554 S::BedPicker { .. } => self.re_bed_candidates().len(),
5555 }
5556 }
5557
5558 pub fn re_sheet_index(&self) -> usize {
5560 use crate::worker_route_editor::RouteEditorSheet as S;
5561 let Some(ed) = self.state.worker_route_editor.as_ref() else {
5562 return 0;
5563 };
5564 match &ed.sheet {
5565 S::AddMenu { index }
5566 | S::WaypointMenu { index }
5567 | S::HarvestPicker { index }
5568 | S::WithdrawContainers { index }
5569 | S::DepositContainers { index }
5570 | S::SellNpcs { index }
5571 | S::CraftBlueprint { index }
5572 | S::BedPicker { index }
5573 | S::WithdrawItems { index, .. }
5574 | S::DepositFilter { index, .. }
5575 | S::SellItem { index, .. } => *index,
5576 _ => 0,
5577 }
5578 }
5579
5580 pub fn re_sheet_move(&mut self, delta: i32) {
5582 use crate::worker_route_editor::RouteEditorSheet as S;
5583 let count = self.re_sheet_row_count();
5584 if count == 0 {
5585 return;
5586 }
5587 let cur = self.re_sheet_index() as i32;
5588 let next = (cur + delta).rem_euclid(count as i32) as usize;
5589 let Some(ed) = self.state.worker_route_editor.as_mut() else {
5590 return;
5591 };
5592 match &mut ed.sheet {
5593 S::AddMenu { index }
5594 | S::WaypointMenu { index }
5595 | S::HarvestPicker { index }
5596 | S::WithdrawContainers { index }
5597 | S::DepositContainers { index }
5598 | S::SellNpcs { index }
5599 | S::CraftBlueprint { index }
5600 | S::BedPicker { index }
5601 | S::WithdrawItems { index, .. }
5602 | S::DepositFilter { index, .. }
5603 | S::SellItem { index, .. } => *index = next,
5604 _ => {}
5605 }
5606 }
5607
5608 pub fn re_sheet_adjust(&mut self, delta: i32) {
5610 use crate::worker_route_editor::RouteEditorSheet as S;
5611 let index = self.re_sheet_index();
5612 let Some(ed) = self.state.worker_route_editor.as_mut() else {
5613 return;
5614 };
5615 match &mut ed.sheet {
5616 S::WithdrawItems { lines, .. } => {
5617 if let Some(line) = lines.get_mut(index) {
5618 line.adjust_qty(delta);
5619 }
5620 }
5621 S::WaitEntry { ticks } => {
5622 *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
5623 }
5624 _ => {}
5625 }
5626 }
5627
5628 pub fn re_sheet_back(&mut self) {
5629 let Some(ed) = self.state.worker_route_editor.as_mut() else {
5630 return;
5631 };
5632 use crate::worker_route_editor::RouteEditorSheet as S;
5633 let was_editing = ed.editing_index.is_some();
5634 let from_top_picker = matches!(
5635 ed.sheet,
5636 S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
5637 );
5638 ed.sheet_back();
5639 if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
5640 self.state
5642 .push_log("Route: left edit sheet — press s to save current stops".to_string());
5643 }
5644 }
5645
5646 pub fn re_at_root_sheet(&self) -> bool {
5648 self.state
5649 .worker_route_editor
5650 .as_ref()
5651 .is_some_and(|ed| matches!(ed.sheet, crate::worker_route_editor::RouteEditorSheet::Stops))
5652 }
5653
5654 pub fn re_open_add_menu(&mut self) {
5655 if let Some(ed) = self.state.worker_route_editor.as_mut() {
5656 ed.open_add_menu();
5657 }
5658 }
5659
5660 pub fn re_open_bed_picker(&mut self) {
5661 let beds = self.re_bed_candidates();
5662 if beds.is_empty() {
5663 self.state
5664 .push_log("Route: place a camp bed first".to_string());
5665 return;
5666 }
5667 let current = self
5668 .state
5669 .worker_route_editor
5670 .as_ref()
5671 .and_then(|ed| ed.lodging_container_id.clone());
5672 let index = current
5673 .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
5674 .unwrap_or(0);
5675 self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
5676 }
5677
5678 fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
5679 if let Some(ed) = self.state.worker_route_editor.as_mut() {
5680 ed.open_sheet(sheet);
5681 }
5682 }
5683
5684 fn re_confirm_stop(
5686 &mut self,
5687 stop: crate::worker_route_editor::WorkerRouteStop,
5688 what: String,
5689 ) {
5690 let appended = self
5691 .state
5692 .worker_route_editor
5693 .as_mut()
5694 .is_some_and(|ed| ed.confirm_stop(stop));
5695 if appended {
5696 self.state.push_log(format!("Route: + {what}"));
5697 } else {
5698 self.state
5699 .push_log(format!("Route: {what} already in route — selected it"));
5700 }
5701 }
5702
5703 fn re_open_withdraw_items(&mut self, container_id: String) {
5704 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
5705 let contents = self.re_container_contents(&container_id);
5706 let existing = self
5710 .state
5711 .worker_route_editor
5712 .as_ref()
5713 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
5714 .and_then(|stop| match stop {
5715 WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
5716 _ => None,
5717 })
5718 .unwrap_or_default();
5719 let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
5720 if let Some(ed) = self.state.worker_route_editor.as_mut() {
5723 let _ = ed.retarget_withdraw_container(container_id.clone());
5724 }
5725 self.re_open_sheet(S::WithdrawItems {
5726 container_id,
5727 lines,
5728 index: 0,
5729 });
5730 }
5731
5732 fn re_withdraw_items_activate(&mut self, index: usize) {
5733 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
5734 enum Outcome {
5735 Cycled,
5736 Confirmed(String),
5737 Empty,
5738 }
5739 let outcome = {
5740 let Some(ed) = self.state.worker_route_editor.as_mut() else {
5741 return;
5742 };
5743 let S::WithdrawItems {
5744 container_id,
5745 lines,
5746 index: sheet_index,
5747 } = &mut ed.sheet
5748 else {
5749 return;
5750 };
5751 *sheet_index = index;
5752 if index < lines.len() {
5753 lines[index].cycle();
5754 Outcome::Cycled
5755 } else {
5756 let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
5757 if items.is_empty() {
5758 Outcome::Empty
5759 } else {
5760 let stop = WorkerRouteStop::WithdrawFrom {
5761 container_id: container_id.clone(),
5762 items,
5763 };
5764 let summary = stop.summary();
5765 ed.confirm_stop(stop);
5766 Outcome::Confirmed(summary)
5767 }
5768 }
5769 };
5770 match outcome {
5771 Outcome::Cycled => {}
5772 Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
5773 Outcome::Empty => self
5774 .state
5775 .push_log("Route: pick at least one item (Space/Enter toggles All/qty)".to_string()),
5776 }
5777 }
5778
5779 fn re_open_deposit_filter(&mut self, container_id: String) {
5780 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
5781 let existing_filter = self
5783 .state
5784 .worker_route_editor
5785 .as_ref()
5786 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
5787 .and_then(|stop| match stop {
5788 WorkerRouteStop::DepositAt { filter, .. } => {
5789 Some(filter.clone().unwrap_or_default())
5790 }
5791 _ => None,
5792 });
5793 let mut candidates = self.re_template_candidates();
5794 if let Some(ref chosen) = existing_filter {
5795 for t in chosen {
5796 if !candidates.iter().any(|c| c == t) {
5797 candidates.push(t.clone());
5798 }
5799 }
5800 candidates.sort();
5801 candidates.dedup();
5802 }
5803 let rows: Vec<(String, bool)> = match existing_filter {
5804 Some(chosen) => candidates
5805 .iter()
5806 .map(|t| (t.clone(), chosen.contains(t)))
5807 .collect(),
5808 None => candidates.into_iter().map(|t| (t, false)).collect(),
5809 };
5810 if let Some(ed) = self.state.worker_route_editor.as_mut() {
5811 let _ = ed.retarget_deposit_container(container_id.clone());
5812 }
5813 self.re_open_sheet(S::DepositFilter {
5814 container_id,
5815 rows,
5816 index: 0,
5817 });
5818 }
5819
5820 fn re_deposit_filter_activate(&mut self, index: usize) {
5821 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
5822 let mut confirmed: Option<String> = None;
5823 {
5824 let Some(ed) = self.state.worker_route_editor.as_mut() else {
5825 return;
5826 };
5827 let S::DepositFilter {
5828 container_id,
5829 rows,
5830 index: sheet_index,
5831 } = &mut ed.sheet
5832 else {
5833 return;
5834 };
5835 *sheet_index = index;
5836 if index < rows.len() {
5837 rows[index].1 = !rows[index].1;
5838 } else {
5839 let chosen: Vec<String> = rows
5841 .iter()
5842 .filter(|(_, on)| *on)
5843 .map(|(t, _)| t.clone())
5844 .collect();
5845 let filter = if chosen.is_empty() { None } else { Some(chosen) };
5846 let stop = WorkerRouteStop::DepositAt {
5847 container_id: container_id.clone(),
5848 filter,
5849 };
5850 confirmed = Some(stop.summary());
5851 ed.confirm_stop(stop);
5852 }
5853 }
5854 if let Some(what) = confirmed {
5855 self.state.push_log(format!("Route: + {what}"));
5856 }
5857 }
5858
5859 fn re_open_sell_item(&mut self, npc_id: Option<String>) {
5860 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
5861 let templates = self.re_template_candidates();
5862 if templates.is_empty() {
5863 self.state.push_log(
5864 "Route: no item templates available — learn a craft recipe or place a harvest node first"
5865 .to_string(),
5866 );
5867 return;
5868 }
5869 let (pre_npc, pre_template, pre_all) = self
5871 .state
5872 .worker_route_editor
5873 .as_ref()
5874 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
5875 .and_then(|stop| match stop {
5876 WorkerRouteStop::TradeWith {
5877 npc_id,
5878 template,
5879 sell_all,
5880 } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
5881 _ => None,
5882 })
5883 .unwrap_or((None, None, true));
5884 let npc_id = npc_id.or(pre_npc);
5885 let index = pre_template
5886 .and_then(|t| templates.iter().position(|x| x == &t))
5887 .map(|i| i + 1) .unwrap_or(1);
5889 self.re_open_sheet(S::SellItem {
5890 npc_id,
5891 templates,
5892 index,
5893 sell_all: pre_all,
5894 });
5895 }
5896
5897 fn re_sell_item_activate(&mut self, index: usize) {
5898 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
5899 let mut confirmed: Option<String> = None;
5900 {
5901 let Some(ed) = self.state.worker_route_editor.as_mut() else {
5902 return;
5903 };
5904 let S::SellItem {
5905 npc_id,
5906 templates,
5907 index: sheet_index,
5908 sell_all,
5909 } = &mut ed.sheet
5910 else {
5911 return;
5912 };
5913 *sheet_index = index;
5914 if index == 0 {
5915 *sell_all = !*sell_all;
5916 } else if let Some(template) = templates.get(index - 1).cloned() {
5917 let stop = WorkerRouteStop::TradeWith {
5918 npc_id: npc_id.clone(),
5919 template,
5920 sell_all: *sell_all,
5921 };
5922 confirmed = Some(stop.summary());
5923 ed.confirm_stop(stop);
5924 }
5925 }
5926 if let Some(what) = confirmed {
5927 self.state.push_log(format!("Route: + {what}"));
5928 }
5929 }
5930
5931 pub fn re_edit_selected_stop(&mut self) {
5933 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
5934 let Some(stop) = self
5935 .state
5936 .worker_route_editor
5937 .as_ref()
5938 .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
5939 else {
5940 self.state
5941 .push_log("Route: no stop selected — press a to add one".to_string());
5942 return;
5943 };
5944 if let Some(ed) = self.state.worker_route_editor.as_mut() {
5945 ed.begin_edit_selected();
5946 }
5947 match stop {
5948 WorkerRouteStop::Waypoint { .. } => {
5949 self.re_open_sheet(S::WaypointMenu { index: 0 });
5950 }
5951 WorkerRouteStop::HarvestNode { node_id } => {
5952 let nodes = self.re_node_candidates();
5953 let index = nodes.iter().position(|n| n.id == node_id).unwrap_or(0);
5954 if nodes.is_empty() {
5955 self.re_cancel_edit();
5956 self.state
5957 .push_log("Route: no harvestable nodes visible to retarget".to_string());
5958 } else {
5959 self.re_open_sheet(S::HarvestPicker { index });
5960 }
5961 }
5962 WorkerRouteStop::WithdrawFrom { container_id, .. } => {
5963 let containers = self.re_container_candidates();
5966 if containers.is_empty() {
5967 self.re_cancel_edit();
5968 self.state
5969 .push_log("Route: place a storage chest first".to_string());
5970 } else {
5971 let index = containers
5972 .iter()
5973 .position(|c| c.id == container_id)
5974 .unwrap_or(0);
5975 self.re_open_sheet(S::WithdrawContainers { index });
5976 }
5977 }
5978 WorkerRouteStop::DepositAt { container_id, .. } => {
5979 let containers = self.re_container_candidates();
5980 if containers.is_empty() {
5981 self.re_cancel_edit();
5982 self.state
5983 .push_log("Route: place a storage chest first".to_string());
5984 } else {
5985 let index = containers
5986 .iter()
5987 .position(|c| c.id == container_id)
5988 .unwrap_or(0);
5989 self.re_open_sheet(S::DepositContainers { index });
5990 }
5991 }
5992 WorkerRouteStop::TradeWith { npc_id, .. } => {
5993 let npcs = self.re_npc_candidates();
5994 let index = npc_id
5996 .as_ref()
5997 .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
5998 .unwrap_or(0);
5999 self.re_open_sheet(S::SellNpcs { index });
6000 }
6001 WorkerRouteStop::CraftAt { blueprint, .. } => {
6002 let bps = self.re_blueprint_ids();
6003 let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
6004 if bps.is_empty() {
6005 self.re_cancel_edit();
6006 self.state
6007 .push_log("Route: no known blueprints to retarget".to_string());
6008 } else {
6009 self.re_open_sheet(S::CraftBlueprint { index });
6010 }
6011 }
6012 WorkerRouteStop::RestIfNeeded => {
6013 self.re_cancel_edit();
6014 self.state
6015 .push_log("Route: rest has no settings (change the bed with l)".to_string());
6016 }
6017 WorkerRouteStop::Wait { wait_ticks } => {
6018 self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
6019 }
6020 }
6021 }
6022
6023 fn re_cancel_edit(&mut self) {
6024 if let Some(ed) = self.state.worker_route_editor.as_mut() {
6025 ed.editing_index = None;
6026 }
6027 }
6028
6029 pub fn worker_route_editor_ui_click(
6032 &mut self,
6033 click: crate::worker_route_editor::RouteEditorClick,
6034 ) {
6035 use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
6036 match click {
6037 RouteEditorClick::SelectStop(i) => {
6038 if let Some(ed) = self.state.worker_route_editor.as_mut() {
6039 ed.sheet = S::Stops;
6040 ed.select_stop(i);
6041 }
6042 }
6043 RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
6044 RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
6045 RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
6046 }
6047 }
6048
6049 pub fn re_sheet_row_activate(&mut self, row: usize) {
6051 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
6052 let Some(sheet) = self
6053 .state
6054 .worker_route_editor
6055 .as_ref()
6056 .map(|ed| ed.sheet.clone())
6057 else {
6058 return;
6059 };
6060 match sheet {
6061 S::Stops => {
6062 if let Some(ed) = self.state.worker_route_editor.as_mut() {
6063 ed.select_stop(row);
6064 }
6065 }
6066 S::AddMenu { .. } => match row {
6067 0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
6068 1 => {
6069 if self.re_node_candidates().is_empty() {
6070 self.state
6071 .push_log("Route: no harvestable nodes visible in this region".to_string());
6072 } else {
6073 self.re_open_sheet(S::HarvestPicker { index: 0 });
6074 }
6075 }
6076 2 | 3 => {
6077 if self.re_container_candidates().is_empty() {
6078 self.state
6079 .push_log("Route: place a storage chest first".to_string());
6080 } else if row == 2 {
6081 self.re_open_sheet(S::WithdrawContainers { index: 0 });
6082 } else {
6083 self.re_open_sheet(S::DepositContainers { index: 0 });
6084 }
6085 }
6086 4 => {
6087 if self.re_template_candidates().is_empty() {
6088 self.state.push_log(
6089 "Route: no item templates available — learn a craft recipe or place a harvest node first"
6090 .to_string(),
6091 );
6092 } else {
6093 self.re_open_sheet(S::SellNpcs { index: 0 });
6094 }
6095 }
6096 5 => {
6097 if self.re_blueprint_ids().is_empty() {
6098 self.state.push_log(
6099 "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
6100 .to_string(),
6101 );
6102 } else {
6103 self.re_open_sheet(S::CraftBlueprint { index: 0 });
6104 }
6105 }
6106 6 => self.re_confirm_stop(
6107 WorkerRouteStop::RestIfNeeded,
6108 "rest if needed".into(),
6109 ),
6110 7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
6111 _ => {}
6112 },
6113 S::WaypointMenu { .. } => match row {
6114 0 => {
6115 let (x, y, z) = self.state.player_position_with_z();
6116 let stop = WorkerRouteStop::Waypoint { x, y, z };
6117 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
6118 }
6119 1 => {
6120 self.re_open_sheet(S::WaypointMapPick);
6121 self.state.push_log("Route: click the map to place the waypoint (Esc to finish)".to_string());
6122 }
6123 _ => {}
6124 },
6125 S::HarvestPicker { .. } => {
6126 let nodes = self.re_node_candidates();
6127 if let Some(n) = nodes.get(row) {
6128 let stop = WorkerRouteStop::HarvestNode {
6129 node_id: n.id.clone(),
6130 };
6131 let label = n.label.clone();
6132 self.re_confirm_stop(stop, format!("harvest {label}"));
6133 }
6134 }
6135 S::WithdrawContainers { .. } => {
6136 let containers = self.re_container_candidates();
6137 if let Some(c) = containers.get(row) {
6138 let id = c.id.clone();
6139 self.re_open_withdraw_items(id);
6140 }
6141 }
6142 S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
6143 S::DepositContainers { .. } => {
6144 let containers = self.re_container_candidates();
6145 if let Some(c) = containers.get(row) {
6146 let id = c.id.clone();
6147 self.re_open_deposit_filter(id);
6148 }
6149 }
6150 S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
6151 S::SellNpcs { .. } => {
6152 let npcs = self.re_npc_candidates();
6153 let npc_id = if row == 0 {
6154 None
6155 } else {
6156 npcs.get(row - 1).map(|n| n.id.clone())
6157 };
6158 if row == 0 || npc_id.is_some() {
6159 self.re_open_sell_item(npc_id);
6160 }
6161 }
6162 S::SellItem { .. } => self.re_sell_item_activate(row),
6163 S::CraftBlueprint { .. } => {
6164 let bps = self.re_blueprint_ids();
6165 if let Some(bp) = bps.get(row) {
6166 let stop = WorkerRouteStop::CraftAt {
6167 device: "hand".into(),
6168 blueprint: bp.clone(),
6169 qty: None,
6170 };
6171 self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
6172 }
6173 }
6174 S::WaitEntry { ticks } => {
6175 let stop = WorkerRouteStop::Wait {
6176 wait_ticks: ticks,
6177 };
6178 self.re_confirm_stop(stop, format!("wait {ticks}t"));
6179 }
6180 S::BedPicker { .. } => {
6181 let beds = self.re_bed_candidates();
6182 if let Some((id, name)) = beds.get(row) {
6183 let (id, name) = (id.clone(), name.clone());
6184 if let Some(ed) = self.state.worker_route_editor.as_mut() {
6185 ed.lodging_container_id = Some(id.clone());
6186 ed.sheet = S::Stops;
6187 }
6188 self.state
6189 .push_log(format!("Route: rest bed set to {name}"));
6190 }
6191 }
6192 S::WaypointMapPick => {}
6193 }
6194 }
6195
6196 pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
6203 use crate::worker_route_editor as wre;
6204 use wre::RouteEditorSheet as S;
6205 if self.state.worker_route_editor.is_none() {
6206 return;
6207 }
6208 let sheet = self
6209 .state
6210 .worker_route_editor
6211 .as_ref()
6212 .map(|ed| ed.sheet.clone())
6213 .unwrap_or(S::Stops);
6214 match sheet {
6215 S::WaypointMapPick => {
6216 let (_, _, z) = self.state.player_position_with_z();
6217 let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
6218 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
6219 let editing = self
6221 .state
6222 .worker_route_editor
6223 .as_ref()
6224 .is_some_and(|ed| ed.editing_index.is_some());
6225 if !editing {
6226 if let Some(ed) = self.state.worker_route_editor.as_mut() {
6227 ed.sheet = S::WaypointMapPick;
6228 }
6229 }
6230 }
6231 S::HarvestPicker { .. } => {
6232 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
6233 let stop = wre::WorkerRouteStop::HarvestNode {
6234 node_id: node.id.clone(),
6235 };
6236 let label = node.label.clone();
6237 self.re_confirm_stop(stop, format!("harvest {label}"));
6238 }
6239 }
6240 S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
6241 if let Some(cid) = wre::pick_storage_container_at(
6243 &self.state.placed_containers,
6244 self.state.character_id,
6245 x,
6246 y,
6247 ) {
6248 self.re_open_withdraw_items(cid);
6249 }
6250 }
6251 S::DepositContainers { .. } | S::DepositFilter { .. } => {
6252 if let Some(cid) = wre::pick_storage_container_at(
6253 &self.state.placed_containers,
6254 self.state.character_id,
6255 x,
6256 y,
6257 ) {
6258 self.re_open_deposit_filter(cid);
6259 }
6260 }
6261 S::SellNpcs { .. } => {
6262 if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
6263 self.re_open_sell_item(Some(npc_id));
6264 }
6265 }
6266 S::SellItem { .. } => {
6267 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
6268 if let Some(ed) = self.state.worker_route_editor.as_mut() {
6269 if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
6270 *slot = Some(npc_id.clone());
6271 }
6272 }
6273 self.state
6274 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
6275 }
6276 }
6277 _ => self.worker_route_editor_quick_add_click(x, y),
6279 }
6280 }
6281
6282 fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
6286 use crate::worker_route_editor as wre;
6287 let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
6288 let dx = ax - bx;
6289 let dy = ay - by;
6290 (dx * dx + dy * dy).sqrt()
6291 };
6292
6293 let selected_stop_kind = self
6296 .state
6297 .worker_route_editor
6298 .as_ref()
6299 .and_then(|ed| ed.stops.get(ed.selected_stop_index))
6300 .map(|s| match s {
6301 wre::WorkerRouteStop::TradeWith { .. } => 1,
6302 wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
6303 _ => 0,
6304 })
6305 .unwrap_or(0);
6306 if selected_stop_kind == 1 {
6307 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
6308 if let Some(ed) = self.state.worker_route_editor.as_mut() {
6309 ed.set_selected_trade_npc(npc_id.clone());
6310 }
6311 self.state
6312 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
6313 return;
6314 }
6315 }
6316 if selected_stop_kind == 2 {
6317 if let Some(cid) = wre::pick_storage_container_at(
6318 &self.state.placed_containers,
6319 self.state.character_id,
6320 x,
6321 y,
6322 ) {
6323 let name = self
6324 .state
6325 .placed_containers
6326 .iter()
6327 .find(|c| c.id == cid)
6328 .map(|c| c.display_name.clone())
6329 .unwrap_or_else(|| "container".into());
6330 if let Some(ed) = self.state.worker_route_editor.as_mut() {
6331 ed.set_selected_withdraw_container(cid.clone());
6332 }
6333 self.state
6334 .push_log(format!("Route: withdraw source → {name}"));
6335 return;
6336 }
6337 }
6338
6339 enum Target {
6342 Bed(String),
6343 Container(String),
6344 Npc(String, String),
6345 Node(String, String),
6346 }
6347 let mut best: Option<(f32, u8, Target)> = None;
6348 let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
6349 let better = match best {
6350 None => true,
6351 Some((bd, brank, _)) => d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank),
6352 };
6353 if better {
6354 *best = Some((d, rank, t));
6355 }
6356 };
6357 if let Some(bed_id) = wre::pick_lodging_container_at(
6358 &self.state.placed_containers,
6359 self.state.character_id,
6360 x,
6361 y,
6362 ) {
6363 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
6364 let already_bed = self
6367 .state
6368 .worker_route_editor
6369 .as_ref()
6370 .is_some_and(|ed| ed.lodging_container_id.as_deref() == Some(bed_id.as_str()));
6371 if already_bed {
6372 consider(dist(x, y, c.x, c.y), 1, Target::Container(bed_id), &mut best);
6373 } else {
6374 consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
6375 }
6376 }
6377 }
6378 if let Some(cid) = wre::pick_storage_container_at(
6379 &self.state.placed_containers,
6380 self.state.character_id,
6381 x,
6382 y,
6383 ) {
6384 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
6385 consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
6386 }
6387 }
6388 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
6389 if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
6390 consider(
6391 dist(x, y, n.x, n.y),
6392 2,
6393 Target::Npc(npc_id, label),
6394 &mut best,
6395 );
6396 }
6397 }
6398 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
6399 let d = dist(x, y, node.x, node.y);
6400 consider(
6401 d,
6402 3,
6403 Target::Node(node.id.clone(), node.label.clone()),
6404 &mut best,
6405 );
6406 }
6407
6408 match best.map(|(_, _, t)| t) {
6409 Some(Target::Bed(bed_id)) => {
6410 let name = self
6411 .state
6412 .placed_containers
6413 .iter()
6414 .find(|c| c.id == bed_id)
6415 .map(|c| c.display_name.clone())
6416 .unwrap_or_else(|| "camp bed".into());
6417 if let Some(ed) = self.state.worker_route_editor.as_mut() {
6418 ed.lodging_container_id = Some(bed_id.clone());
6419 }
6420 self.state
6421 .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
6422 }
6423 Some(Target::Container(cid)) => {
6424 let name = self
6425 .state
6426 .placed_containers
6427 .iter()
6428 .find(|c| c.id == cid)
6429 .map(|c| c.display_name.clone())
6430 .unwrap_or_else(|| "container".into());
6431 let added = self
6432 .state
6433 .worker_route_editor
6434 .as_mut()
6435 .is_some_and(|ed| ed.append_deposit_at(&cid));
6436 if added {
6437 self.state
6438 .push_log(format!("Route: + deposit at {name} ({cid})"));
6439 } else {
6440 self.state.push_log(format!(
6441 "Route: {name} already in route — selected it (d to remove)"
6442 ));
6443 }
6444 }
6445 Some(Target::Npc(npc_id, label)) => {
6446 let template = self.re_template_candidates().into_iter().next();
6449 let Some(template) = template else {
6450 self.state.push_log("Route: no items in your storage to sell — stock a chest first".to_string());
6451 return;
6452 };
6453 let added = self
6454 .state
6455 .worker_route_editor
6456 .as_mut()
6457 .is_some_and(|ed| ed.append_trade_with(template.clone(), Some(npc_id.clone()), true));
6458 if added {
6459 self.state
6460 .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
6461 } else {
6462 self.state.push_log(format!(
6463 "Route: {label} already sells {template} — selected it (d to remove)"
6464 ));
6465 }
6466 }
6467 Some(Target::Node(id, label)) => {
6468 let added = self
6469 .state
6470 .worker_route_editor
6471 .as_mut()
6472 .is_some_and(|ed| ed.append_harvest_node(&id));
6473 if added {
6474 self.state
6475 .push_log(format!("Route: + harvest node {label} ({id})"));
6476 } else {
6477 self.state.push_log(format!(
6478 "Route: {label} already in route — selected it (d to remove)"
6479 ));
6480 }
6481 }
6482 None => {}
6483 }
6484 }
6485
6486 pub fn worker_route_editor_select(&mut self, delta: i32) {
6487 let Some(ed) = self.state.worker_route_editor.as_mut() else {
6488 return;
6489 };
6490 if ed.stops.is_empty() {
6491 return;
6492 }
6493 let n = ed.stops.len() as i32;
6494 let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
6495 ed.selected_stop_index = next;
6496 }
6497
6498 pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
6499 let Some(ed) = self.state.worker_route_editor.as_mut() else {
6500 return;
6501 };
6502 if delta < 0 {
6503 ed.move_selected_up();
6504 } else if delta > 0 {
6505 ed.move_selected_down();
6506 }
6507 }
6508
6509 pub fn worker_route_editor_delete_selected(&mut self) {
6510 let removed = self
6511 .state
6512 .worker_route_editor
6513 .as_mut()
6514 .is_some_and(|ed| {
6515 let before = ed.stop_count();
6516 ed.remove_selected_stop();
6517 ed.stop_count() < before
6518 });
6519 if removed {
6520 self.state.push_log("Route: removed selected stop");
6521 }
6522 }
6523
6524 pub fn worker_route_editor_clear_stops(&mut self) {
6527 let Some(ed) = self.state.worker_route_editor.as_mut() else {
6528 return;
6529 };
6530 if ed.stops.is_empty() {
6531 self.state.push_log("Route: already empty — s saves an idle worker".to_string());
6532 return;
6533 }
6534 ed.stops.clear();
6535 ed.selected_stop_index = 0;
6536 self.state
6537 .push_log("Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string());
6538 }
6539
6540 pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
6541 if self.state.pending_worker_job_ack.is_some() {
6542 anyhow::bail!("route save still pending — wait for server ack");
6543 }
6544 let Some(ed) = self.state.worker_route_editor.clone() else {
6545 anyhow::bail!("route editor not open");
6546 };
6547 let (job_yaml, idle) = if ed.stops.is_empty() {
6550 (ed.build_idle_job_yaml(), true)
6551 } else {
6552 (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
6553 };
6554 let worker_id = ed.worker_instance_id.clone();
6555 let route_view = if idle {
6556 None
6557 } else {
6558 Some(ed.to_route_view())
6559 };
6560 let mode = if idle {
6561 flatland_protocol::WorkerModeView::Idle
6562 } else {
6563 flatland_protocol::WorkerModeView::JobLoop
6564 };
6565 let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
6566 .state
6567 .hired_workers
6568 .iter()
6569 .find(|w| w.instance_id == worker_id)
6570 .map(|w| {
6571 (
6572 w.route.clone(),
6573 w.mode,
6574 w.step_label.clone(),
6575 w.last_error.clone(),
6576 )
6577 })
6578 .unwrap_or((
6579 None,
6580 flatland_protocol::WorkerModeView::Idle,
6581 String::new(),
6582 None,
6583 ));
6584 self.seq += 1;
6585 let seq = self.seq;
6586 self.session
6587 .submit_intent(Intent::SetWorkerJob {
6588 entity_id: self.state.entity_id,
6589 worker_instance_id: worker_id.clone(),
6590 job_yaml,
6591 seq,
6592 })
6593 .await?;
6594 self.state.intents_sent += 1;
6595 if let Some(w) = self
6596 .state
6597 .hired_workers
6598 .iter_mut()
6599 .find(|w| w.instance_id == worker_id)
6600 {
6601 w.route = route_view;
6602 w.mode = mode;
6603 w.last_error = None;
6604 if idle {
6605 w.step_label.clear();
6606 }
6607 }
6608 self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
6609 seq,
6610 worker_instance_id: worker_id,
6611 worker_label: ed.worker_label.clone(),
6612 idle,
6613 stop_count: ed.stops.len(),
6614 prev_route,
6615 prev_mode,
6616 prev_step_label,
6617 prev_last_error,
6618 });
6619 self.state.push_log(format!(
6620 "Route: saving for {}… (waiting for server)",
6621 ed.worker_label
6622 ));
6623 Ok(())
6625 }
6626 pub fn quest_menu_move(&mut self, delta: i32) {
6627 let n = self.state.active_quest_entries().len();
6628 if n == 0 {
6629 return;
6630 }
6631 let idx = self.state.quest_menu_index as i32;
6632 self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
6633 }
6634
6635 pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
6636 let Some(offer) = self.state.pending_quest_offer.clone() else {
6637 anyhow::bail!("no quest offer");
6638 };
6639 self.seq += 1;
6640 let seq = self.seq;
6641 self.session
6642 .submit_intent(Intent::AcceptQuest {
6643 entity_id: self.state.entity_id,
6644 quest_id: offer.quest_id,
6645 seq,
6646 })
6647 .await?;
6648 self.state.intents_sent += 1;
6649 Ok(())
6650 }
6651
6652 pub fn quest_offer_decline(&mut self) {
6653 self.state.show_quest_offer = false;
6654 self.state.pending_quest_offer = None;
6655 if !self.state.show_npc_chat
6656 && !self.state.show_shop_menu
6657 && self.state.npc_verb_target.is_some()
6658 {
6659 self.state.show_npc_verb_menu = true;
6660 }
6661 }
6662
6663 pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
6664 if !self.state.show_quest_menu {
6665 return Ok(());
6666 }
6667 let active: Vec<_> = self
6668 .state
6669 .active_quest_entries()
6670 .into_iter()
6671 .cloned()
6672 .collect();
6673 let Some(entry) = active.get(self.state.quest_menu_index) else {
6674 return Ok(());
6675 };
6676 if self.state.quest_withdraw_confirm {
6677 if !entry.can_withdraw {
6678 anyhow::bail!("quest cannot be withdrawn");
6679 }
6680 self.seq += 1;
6681 let seq = self.seq;
6682 self.session
6683 .submit_intent(Intent::WithdrawQuest {
6684 entity_id: self.state.entity_id,
6685 quest_id: entry.quest_id.clone(),
6686 seq,
6687 })
6688 .await?;
6689 self.state.intents_sent += 1;
6690 self.state.quest_withdraw_confirm = false;
6691 return Ok(());
6692 }
6693 self.seq += 1;
6694 let seq = self.seq;
6695 self.session
6696 .submit_intent(Intent::TrackQuest {
6697 entity_id: self.state.entity_id,
6698 quest_id: entry.quest_id.clone(),
6699 seq,
6700 })
6701 .await?;
6702 self.state.intents_sent += 1;
6703 Ok(())
6704 }
6705
6706 pub fn quest_request_withdraw(&mut self) {
6707 if self.state.show_quest_menu {
6708 self.state.quest_withdraw_confirm = true;
6709 }
6710 }
6711
6712 pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
6713 if !self.state.is_alive() {
6714 anyhow::bail!("you are dead");
6715 }
6716 let Some(catalog) = self.state.shop_catalog.clone() else {
6717 anyhow::bail!("no shop open");
6718 };
6719 self.seq += 1;
6720 let seq = self.seq;
6721 match self.state.shop_tab {
6722 ShopTab::Buy => {
6723 let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
6724 anyhow::bail!("nothing selected");
6725 };
6726 if offer.already_owned {
6727 anyhow::bail!("already owned");
6728 }
6729 self.session
6730 .submit_intent(Intent::ShopBuy {
6731 entity_id: self.state.entity_id,
6732 npc_id: catalog.npc_id.clone(),
6733 offer_id: offer.offer_id.clone(),
6734 quantity: self.state.shop_quantity,
6735 seq,
6736 })
6737 .await?;
6738 }
6739 ShopTab::Sell => {
6740 let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
6741 anyhow::bail!("nothing to sell");
6742 };
6743 if line.quantity == 0 {
6744 anyhow::bail!("you have no {}", line.label);
6745 }
6746 let quantity = self.state.shop_quantity.min(line.quantity).max(1);
6747 self.session
6748 .submit_intent(Intent::ShopSell {
6749 entity_id: self.state.entity_id,
6750 npc_id: catalog.npc_id.clone(),
6751 template_id: line.template_id.clone(),
6752 quantity,
6753 seq,
6754 })
6755 .await?;
6756 }
6757 }
6758 self.state.intents_sent += 1;
6759 Ok(())
6760 }
6761
6762 pub fn craft_menu_move(&mut self, delta: i32) {
6763 let n = self.state.blueprints.len();
6764 if n == 0 {
6765 return;
6766 }
6767 let idx = self.state.craft_menu_index as i32;
6768 let next = (idx + delta).rem_euclid(n as i32);
6769 self.state.craft_menu_index = next as usize;
6770 self.state.clamp_craft_batch_quantity();
6771 }
6772
6773 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
6774 self.state.craft_batch_adjust_quantity(delta);
6775 }
6776
6777 pub fn craft_batch_set_max(&mut self) {
6778 self.state.craft_batch_set_max();
6779 }
6780
6781 pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
6782 let Some(blueprint) = self
6783 .state
6784 .blueprints
6785 .get(self.state.craft_menu_index)
6786 .cloned()
6787 else {
6788 anyhow::bail!("no blueprints known");
6789 };
6790 if !self.state.can_craft_blueprint(&blueprint) {
6791 let hint = self
6792 .state
6793 .craft_missing_hint(&blueprint)
6794 .unwrap_or_else(|| "missing materials".into());
6795 anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
6796 }
6797 let count = self.state.craft_batch_quantity;
6798 let max = self.state.max_craft_batches(&blueprint);
6799 if max == 0 {
6800 anyhow::bail!("cannot craft {}", blueprint.label);
6801 }
6802 let batches = count.min(max);
6803 self.craft(&blueprint.id, Some(batches)).await?;
6804 self.state.show_craft_menu = false;
6805 Ok(())
6806 }
6807
6808 pub async fn move_by(
6809 &mut self,
6810 forward: f32,
6811 strafe: f32,
6812 vertical: f32,
6813 sprint: bool,
6814 ) -> anyhow::Result<()> {
6815 if !self.state.is_alive() {
6816 anyhow::bail!("you are dead");
6817 }
6818 if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
6819 self.last_move_forward = forward;
6820 self.last_move_strafe = strafe;
6821 }
6822 self.seq += 1;
6823 self.session
6824 .submit_intent(Intent::Move {
6825 entity_id: self.state.entity_id,
6826 forward,
6827 strafe,
6828 vertical,
6829 sprint,
6830 seq: self.seq,
6831 })
6832 .await?;
6833 self.state.intents_sent += 1;
6834 Ok(())
6835 }
6836
6837 pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
6838 if !self.state.connected {
6839 crate::harvest_trace!("harvest_nearest rejected: not connected");
6840 anyhow::bail!("not connected");
6841 }
6842 if !self.state.is_alive() {
6843 crate::harvest_trace!("harvest_nearest rejected: player dead");
6844 anyhow::bail!("you are dead");
6845 }
6846 if self.state.harvest_in_progress {
6847 if self.state.harvest_state_stale() {
6848 self.state.clear_harvest_state();
6849 } else {
6850 anyhow::bail!("already harvesting");
6851 }
6852 }
6853 let (px, py) = self
6854 .state
6855 .player
6856 .as_ref()
6857 .map(|p| (p.transform.position.x, p.transform.position.y))
6858 .unwrap_or((0.0, 0.0));
6859
6860 let available = self
6861 .state
6862 .resource_nodes
6863 .iter()
6864 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
6865 .count();
6866 let node_id = self
6867 .state
6868 .resource_nodes
6869 .iter()
6870 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
6871 .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
6872 .min_by(|a, b| {
6873 let da = distance(px, py, a.x, a.y);
6874 let db = distance(px, py, b.x, b.y);
6875 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
6876 })
6877 .map(|n| n.id.clone());
6878
6879 let Some(node_id) = node_id else {
6880 let has_loot = self
6881 .state
6882 .ground_drops
6883 .iter()
6884 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
6885 if has_loot {
6886 return self.pickup_nearest().await;
6887 }
6888 anyhow::bail!(
6889 "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
6890 );
6891 };
6892
6893 self.seq += 1;
6894 let seq = self.seq;
6895 crate::harvest_trace!(
6896 entity_id = self.state.entity_id,
6897 node_id = %node_id,
6898 seq,
6899 px,
6900 py,
6901 available_nodes = available,
6902 "submitting harvest intent"
6903 );
6904 self.session
6905 .submit_intent(Intent::Harvest {
6906 entity_id: self.state.entity_id,
6907 node_id,
6908 seq,
6909 })
6910 .await?;
6911 self.state.intents_sent += 1;
6912 self.state.harvest_in_progress = true;
6913 self.state.harvest_started_at = Some(Instant::now());
6914 self.state.push_log("Harvesting…");
6915 crate::harvest_trace!(
6916 entity_id = self.state.entity_id,
6917 seq,
6918 "harvest intent queued to session"
6919 );
6920 Ok(())
6921 }
6922
6923 pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
6924 if !self.state.is_alive() {
6925 anyhow::bail!("you are dead");
6926 }
6927 let blueprint_id = self
6928 .state
6929 .blueprints
6930 .iter()
6931 .find(|bp| self.state.can_craft_blueprint(bp))
6932 .map(|bp| bp.id.clone())
6933 .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
6934 self.craft(&blueprint_id, None).await
6935 }
6936
6937 pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
6938 if !self.state.is_alive() {
6939 anyhow::bail!("you are dead");
6940 }
6941 self.seq += 1;
6942 self.session
6943 .submit_intent(Intent::Craft {
6944 entity_id: self.state.entity_id,
6945 blueprint_id: blueprint_id.to_string(),
6946 count,
6947 seq: self.seq,
6948 })
6949 .await?;
6950 self.state.intents_sent += 1;
6951 let (label, batches) = self
6952 .state
6953 .blueprints
6954 .iter()
6955 .find(|b| b.id == blueprint_id)
6956 .map(|b| {
6957 let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
6958 (b.label.as_str(), n)
6959 })
6960 .unwrap_or((blueprint_id, count.unwrap_or(1)));
6961 self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
6962 Ok(())
6963 }
6964
6965 pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
6966 if !self.state.is_alive() {
6967 anyhow::bail!("you are dead");
6968 }
6969 let target_id = match self.state.nearest_interact_target() {
6970 Some(id) => id,
6971 None => {
6972 anyhow::bail!("nothing to interact with nearby");
6973 }
6974 };
6975 if self.state.npcs.iter().any(|n| n.id == target_id) {
6976 self.state.show_npc_verb_menu = true;
6977 self.state.npc_verb_target = Some(target_id);
6978 self.state.npc_verb_index = 0;
6979 return Ok(());
6980 }
6981 self.seq += 1;
6982 self.session
6983 .submit_intent(Intent::Interact {
6984 entity_id: self.state.entity_id,
6985 target_id: target_id.clone(),
6986 seq: self.seq,
6987 })
6988 .await?;
6989 self.state.intents_sent += 1;
6990 Ok(())
6991 }
6992
6993 pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
6995 if !self.state.is_alive() {
6996 anyhow::bail!("you are dead");
6997 }
6998 if self.state.nearest_interact_target().is_some() {
6999 return self.interact_nearest().await;
7000 }
7001 if let Some((label, dist)) = self.state.nearest_quest_board() {
7004 if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
7005 anyhow::bail!(
7006 "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
7007 );
7008 }
7009 }
7010 let (px, py) = self.state.player_position();
7011 let has_loot = self
7012 .state
7013 .ground_drops
7014 .iter()
7015 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
7016 if has_loot {
7017 return self.pickup_nearest().await;
7018 }
7019 if self
7020 .state
7021 .placed_containers
7022 .iter()
7023 .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
7024 {
7025 return self.pickup_nearest_container().await;
7026 }
7027 match self.harvest_nearest().await {
7028 Ok(()) => Ok(()),
7029 Err(err) => {
7030 let msg = err.to_string();
7031 if msg.contains("no harvestable")
7032 || msg.contains("press p")
7033 || msg.contains("press f")
7034 {
7035 anyhow::bail!(
7036 "nothing to use nearby — stand by an NPC/door, loot (*), chest, or resource"
7037 );
7038 }
7039 Err(err)
7040 }
7041 }
7042 }
7043
7044 pub async fn cast_hotbar_ability(&mut self, ability_id: &str) -> anyhow::Result<()> {
7046 if !self.state.is_alive() {
7047 anyhow::bail!("you are dead");
7048 }
7049 let target = if ability_id == "heal_touch" {
7050 Some(
7051 self.state
7052 .target_for_slot(2)
7053 .unwrap_or(self.state.entity_id),
7054 )
7055 } else {
7056 self.state
7057 .target_for_slot(1)
7058 .or_else(|| self.state.target_for_slot(2))
7059 };
7060 let Some(target_id) = target else {
7061 anyhow::bail!("no target — Tab to select, then press the hotbar key");
7062 };
7063 self.cast_ability(ability_id, Some(target_id)).await
7064 }
7065
7066 pub fn npc_verb_options(&self) -> Vec<&'static str> {
7067 self.state.npc_verb_options()
7068 }
7069
7070 pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
7071 let Some(npc_id) = self.state.npc_verb_target.clone() else {
7072 return Ok(());
7073 };
7074 let options = self.npc_verb_options();
7075 let choice = options
7076 .get(self.state.npc_verb_index)
7077 .copied()
7078 .unwrap_or("Talk");
7079 self.seq += 1;
7080 match choice {
7081 "Trade" => {
7082 self.session
7083 .submit_intent(Intent::Interact {
7084 entity_id: self.state.entity_id,
7085 target_id: npc_id,
7086 seq: self.seq,
7087 })
7088 .await?;
7089 }
7090 _ => {
7091 self.session
7092 .submit_intent(Intent::NpcTalkOpen {
7093 entity_id: self.state.entity_id,
7094 npc_id,
7095 seq: self.seq,
7096 })
7097 .await?;
7098 }
7099 }
7100 self.state.intents_sent += 1;
7101 Ok(())
7102 }
7103
7104 pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
7105 let Some(chat) = self.state.npc_chat.clone() else {
7106 return Ok(());
7107 };
7108 let message = chat.input.trim().to_string();
7109 if message.is_empty() || chat.pending {
7110 return Ok(());
7111 }
7112 if let Some(c) = self.state.npc_chat.as_mut() {
7113 c.lines.push(format!("You: {message}"));
7114 c.input.clear();
7115 c.pending = true;
7116 }
7117 self.seq += 1;
7118 self.session
7119 .submit_intent(Intent::NpcTalkSay {
7120 entity_id: self.state.entity_id,
7121 npc_id: chat.npc_id,
7122 message,
7123 seq: self.seq,
7124 })
7125 .await?;
7126 self.state.intents_sent += 1;
7127 Ok(())
7128 }
7129
7130 pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
7131 let return_to_verbs = self.state.npc_verb_target.is_some();
7132 let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
7133 self.state.show_npc_chat = false;
7134 if return_to_verbs {
7135 self.state.show_npc_verb_menu = true;
7136 }
7137 return Ok(());
7138 };
7139 self.seq += 1;
7140 self.session
7141 .submit_intent(Intent::NpcTalkClose {
7142 entity_id: self.state.entity_id,
7143 npc_id,
7144 seq: self.seq,
7145 })
7146 .await?;
7147 self.state.intents_sent += 1;
7148 self.state.show_npc_chat = false;
7149 self.state.npc_chat = None;
7150 if return_to_verbs {
7151 self.state.show_npc_verb_menu = true;
7152 }
7153 Ok(())
7154 }
7155
7156 pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
7158 if self.state.show_quest_offer
7159 && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
7160 {
7161 self.quest_offer_decline();
7162 return Ok(());
7163 }
7164 if self.state.show_npc_chat {
7165 return self.npc_talk_close().await;
7166 }
7167 if self.state.show_shop_menu {
7168 self.back_from_shop_menu();
7169 return Ok(());
7170 }
7171 if self.state.show_npc_verb_menu {
7172 self.state.show_npc_verb_menu = false;
7173 self.state.npc_verb_target = None;
7174 }
7175 Ok(())
7176 }
7177
7178 pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
7179 self.seq += 1;
7180 self.session
7181 .submit_intent(Intent::TestDamage {
7182 entity_id: self.state.entity_id,
7183 amount,
7184 seq: self.seq,
7185 })
7186 .await?;
7187 self.state.intents_sent += 1;
7188 Ok(())
7189 }
7190
7191 pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
7192 self.cycle_combat_target_slot(1, reverse).await
7193 }
7194
7195 pub async fn cycle_combat_target_slot(
7196 &mut self,
7197 slot_index: u8,
7198 reverse: bool,
7199 ) -> anyhow::Result<()> {
7200 if !self.state.is_alive() {
7201 anyhow::bail!("you are dead");
7202 }
7203 let candidates = self.state.candidates_for_slot(slot_index);
7204 if candidates.is_empty() {
7205 anyhow::bail!("no targets nearby");
7206 }
7207 let current = self.state.target_for_slot(slot_index);
7208 let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
7209 let next_idx = match idx {
7210 None => 0,
7211 Some(i) if reverse => {
7212 if i == 0 {
7213 candidates.len() - 1
7214 } else {
7215 i - 1
7216 }
7217 }
7218 Some(i) => (i + 1) % candidates.len(),
7219 };
7220 if idx == Some(next_idx) && candidates.len() == 1 {
7221 self.clear_combat_target_slot(slot_index).await?;
7222 return Ok(());
7223 }
7224 let (target_id, label) = candidates[next_idx].clone();
7225 self.set_combat_target_slot(slot_index, target_id, &label)
7226 .await
7227 }
7228
7229 pub async fn set_combat_target_slot(
7230 &mut self,
7231 slot_index: u8,
7232 target_id: EntityId,
7233 label: &str,
7234 ) -> anyhow::Result<()> {
7235 if !self.state.is_alive() {
7236 anyhow::bail!("you are dead");
7237 }
7238 self.seq += 1;
7239 self.session
7240 .submit_intent(Intent::SetTargetSlot {
7241 entity_id: self.state.entity_id,
7242 slot_index,
7243 target_id,
7244 seq: self.seq,
7245 })
7246 .await?;
7247 self.state.intents_sent += 1;
7248 if slot_index == 1 {
7249 self.state.combat_target = Some(target_id);
7250 self.state.combat_target_label = Some(label.to_string());
7251 }
7252 self.state
7253 .push_log(format!("Slot {slot_index} target: {label}"));
7254 Ok(())
7255 }
7256
7257 pub async fn set_combat_target(
7258 &mut self,
7259 target_id: EntityId,
7260 label: &str,
7261 ) -> anyhow::Result<()> {
7262 self.set_combat_target_slot(1, target_id, label).await
7263 }
7264
7265 pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
7266 if slot_index == 1 && self.state.combat_target.is_none() {
7267 return Ok(());
7268 }
7269 self.seq += 1;
7270 self.session
7271 .submit_intent(Intent::ClearTargetSlot {
7272 entity_id: self.state.entity_id,
7273 slot_index,
7274 seq: self.seq,
7275 })
7276 .await?;
7277 if slot_index == 1 {
7278 self.state.combat_target = None;
7279 self.state.combat_target_label = None;
7280 }
7281 self.state.intents_sent += 1;
7282 self.state
7283 .push_log(format!("Slot {slot_index} target cleared"));
7284 Ok(())
7285 }
7286
7287 pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
7288 self.clear_combat_target_slot(1).await
7289 }
7290
7291 pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
7292 if !self.state.is_alive() {
7293 anyhow::bail!("you are dead");
7294 }
7295 self.seq += 1;
7296 self.session
7297 .submit_intent(Intent::AdvanceRotation {
7298 entity_id: self.state.entity_id,
7299 slot_index,
7300 seq: self.seq,
7301 })
7302 .await?;
7303 self.state.intents_sent += 1;
7304 Ok(())
7305 }
7306
7307 pub async fn assign_slot_preset(
7308 &mut self,
7309 slot_index: u8,
7310 preset_id: &str,
7311 ) -> anyhow::Result<()> {
7312 if !self.state.is_alive() {
7313 anyhow::bail!("you are dead");
7314 }
7315 self.seq += 1;
7316 self.session
7317 .submit_intent(Intent::AssignSlotPreset {
7318 entity_id: self.state.entity_id,
7319 slot_index,
7320 preset_id: preset_id.to_string(),
7321 seq: self.seq,
7322 })
7323 .await?;
7324 self.state.intents_sent += 1;
7325 if let Some(slot) = self
7326 .state
7327 .combat_slots
7328 .iter_mut()
7329 .find(|s| s.slot_index == slot_index)
7330 {
7331 slot.preset_id = Some(preset_id.to_string());
7332 if let Some(preset) = self
7333 .state
7334 .rotation_presets
7335 .iter()
7336 .find(|p| p.id == preset_id)
7337 {
7338 slot.preset_label = Some(preset.label.clone());
7339 slot.rotation = preset.abilities.clone();
7340 slot.rotation_index = 0;
7341 }
7342 }
7343 self.state
7344 .push_log(format!("T{slot_index} loadout → {preset_id}"));
7345 Ok(())
7346 }
7347
7348 pub async fn cast_ability(
7349 &mut self,
7350 ability_id: &str,
7351 target_id: Option<EntityId>,
7352 ) -> anyhow::Result<()> {
7353 if !self.state.is_alive() {
7354 anyhow::bail!("you are dead");
7355 }
7356 let target_id = target_id
7357 .or_else(|| self.state.target_for_slot(2))
7358 .or_else(|| self.state.target_for_slot(1))
7359 .unwrap_or(self.state.entity_id);
7360 self.seq += 1;
7361 self.session
7362 .submit_intent(Intent::Cast {
7363 entity_id: self.state.entity_id,
7364 ability_id: ability_id.to_string(),
7365 target_id,
7366 seq: self.seq,
7367 })
7368 .await?;
7369 self.state.intents_sent += 1;
7370 self.state
7371 .push_log(format!("Cast {ability_id} → {target_id}"));
7372 Ok(())
7373 }
7374
7375 pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
7376 self.seq += 1;
7377 self.session
7378 .submit_intent(Intent::UpsertRotationPreset {
7379 entity_id: self.state.entity_id,
7380 preset: preset.clone(),
7381 seq: self.seq,
7382 })
7383 .await?;
7384 self.state.intents_sent += 1;
7385 if let Some(existing) = self
7386 .state
7387 .rotation_presets
7388 .iter_mut()
7389 .find(|p| p.id == preset.id)
7390 {
7391 *existing = preset.clone();
7392 } else {
7393 self.state.rotation_presets.push(preset.clone());
7394 }
7395 for slot in &mut self.state.combat_slots {
7396 if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
7397 slot.preset_label = Some(preset.label.clone());
7398 slot.rotation = preset.abilities.clone();
7399 }
7400 }
7401 self.state
7402 .push_log(format!("Saved rotation: {}", preset.label));
7403 Ok(())
7404 }
7405
7406 pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
7407 self.seq += 1;
7408 self.session
7409 .submit_intent(Intent::DeleteRotationPreset {
7410 entity_id: self.state.entity_id,
7411 preset_id: preset_id.to_string(),
7412 seq: self.seq,
7413 })
7414 .await?;
7415 self.state.intents_sent += 1;
7416 self.state.rotation_presets.retain(|p| p.id != preset_id);
7417 for slot in &mut self.state.combat_slots {
7418 if slot.preset_id.as_deref() == Some(preset_id) {
7419 slot.preset_id = None;
7420 slot.preset_label = None;
7421 slot.rotation.clear();
7422 slot.rotation_index = 0;
7423 }
7424 }
7425 self.state
7426 .push_log(format!("Deleted rotation: {preset_id}"));
7427 Ok(())
7428 }
7429
7430 pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
7431 if !self.state.is_alive() {
7432 anyhow::bail!("you are dead");
7433 }
7434 let enabled = !self
7435 .state
7436 .combat_slots
7437 .iter()
7438 .find(|s| s.slot_index == slot_index)
7439 .map(|s| s.auto_enabled)
7440 .unwrap_or(false);
7441 self.seq += 1;
7442 self.session
7443 .submit_intent(Intent::SetAutoAttack {
7444 entity_id: self.state.entity_id,
7445 slot_index,
7446 enabled,
7447 seq: self.seq,
7448 })
7449 .await?;
7450 if slot_index == 1 {
7451 self.state.auto_attack = enabled;
7452 }
7453 self.state.intents_sent += 1;
7454 self.state.push_log(format!(
7455 "T{slot_index} auto {}",
7456 if enabled { "ON" } else { "OFF" }
7457 ));
7458 Ok(())
7459 }
7460
7461 pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
7462 if !self.state.connected {
7463 anyhow::bail!("not connected");
7464 }
7465 if !self.state.is_alive() {
7466 anyhow::bail!("you are dead");
7467 }
7468 let (px, py) = self.state.player_position();
7469 if self
7470 .state
7471 .ground_drops
7472 .iter()
7473 .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
7474 {
7475 anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
7476 }
7477 self.seq += 1;
7478 self.session
7479 .submit_intent(Intent::Pickup {
7480 entity_id: self.state.entity_id,
7481 drop_id: None,
7482 seq: self.seq,
7483 })
7484 .await?;
7485 self.state.intents_sent += 1;
7486 Ok(())
7487 }
7488
7489 pub async fn toggle_auto_attack(&mut self) -> anyhow::Result<()> {
7490 self.toggle_auto_attack_slot(1).await
7491 }
7492
7493 pub async fn dodge(&mut self) -> anyhow::Result<()> {
7494 if !self.state.is_alive() {
7495 anyhow::bail!("you are dead");
7496 }
7497 self.seq += 1;
7498 self.session
7499 .submit_intent(Intent::Dodge {
7500 entity_id: self.state.entity_id,
7501 seq: self.seq,
7502 })
7503 .await?;
7504 self.state.intents_sent += 1;
7505 self.state.push_log("Dodge!");
7506 Ok(())
7507 }
7508
7509 pub async fn lunge(&mut self) -> anyhow::Result<()> {
7510 if !self.state.is_alive() {
7511 anyhow::bail!("you are dead");
7512 }
7513 let (forward, strafe) = self.last_move_axes();
7514 self.seq += 1;
7515 self.session
7516 .submit_intent(Intent::Lunge {
7517 entity_id: self.state.entity_id,
7518 forward,
7519 strafe,
7520 seq: self.seq,
7521 })
7522 .await?;
7523 self.state.intents_sent += 1;
7524 self.state.push_log("Lunge!");
7525 Ok(())
7526 }
7527
7528 pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
7529 if !self.state.is_alive() {
7530 anyhow::bail!("you are dead");
7531 }
7532 self.seq += 1;
7533 self.session
7534 .submit_intent(Intent::DirectionalJump {
7535 entity_id: self.state.entity_id,
7536 forward,
7537 strafe,
7538 seq: self.seq,
7539 })
7540 .await?;
7541 self.state.intents_sent += 1;
7542 self.state.push_log("Jump!");
7543 Ok(())
7544 }
7545
7546 pub fn last_move_axes(&self) -> (f32, f32) {
7548 (self.last_move_forward, self.last_move_strafe)
7549 }
7550
7551 pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
7552 if !self.state.is_alive() {
7553 anyhow::bail!("you are dead");
7554 }
7555 self.seq += 1;
7556 self.session
7557 .submit_intent(Intent::Block {
7558 entity_id: self.state.entity_id,
7559 enabled,
7560 seq: self.seq,
7561 })
7562 .await?;
7563 self.state.intents_sent += 1;
7564 if enabled {
7565 self.state.push_log("Blocking");
7566 }
7567 Ok(())
7568 }
7569
7570 pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
7571 if !self.state.is_alive() {
7572 anyhow::bail!("you are dead");
7573 }
7574 self.seq += 1;
7575 self.session
7576 .submit_intent(Intent::EquipMainhand {
7577 entity_id: self.state.entity_id,
7578 template_id,
7579 seq: self.seq,
7580 })
7581 .await?;
7582 self.state.intents_sent += 1;
7583 Ok(())
7584 }
7585
7586 pub async fn say(
7587 &mut self,
7588 channel: flatland_protocol::ChatChannel,
7589 text: &str,
7590 ) -> anyhow::Result<()> {
7591 self.seq += 1;
7592 self.session
7593 .submit_intent(Intent::Say {
7594 entity_id: self.state.entity_id,
7595 channel,
7596 text: text.to_string(),
7597 seq: self.seq,
7598 })
7599 .await?;
7600 self.state.intents_sent += 1;
7601 Ok(())
7602 }
7603
7604 pub async fn stop(&mut self) -> anyhow::Result<()> {
7605 self.seq += 1;
7606 self.session
7607 .submit_intent(Intent::Stop {
7608 entity_id: self.state.entity_id,
7609 seq: self.seq,
7610 })
7611 .await?;
7612 self.state.intents_sent += 1;
7613 Ok(())
7614 }
7615
7616 pub fn disconnect(&self) {
7617 self.session.disconnect();
7618 }
7619}
7620
7621fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
7622 let dx = ax - bx;
7623 let dy = ay - by;
7624 (dx * dx + dy * dy).sqrt()
7625}
7626
7627#[cfg(test)]
7628mod tests {
7629 use std::collections::BTreeMap;
7630
7631 use super::*;
7632 use flatland_protocol::{
7633 BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
7634 };
7635
7636 fn sample_state() -> GameState {
7637 let mut state = GameState {
7638 session_id: 1,
7639 entity_id: 1,
7640 character_id: None,
7641 tick: 0,
7642 chunk_rev: 0,
7643 content_rev: 0,
7644 publish_rev: 0,
7645 entities: vec![EntityState {
7646 id: 1,
7647 label: "You".into(),
7648 transform: Transform {
7649 position: WorldCoord::surface(128.0, 128.0),
7650 yaw: 0.0,
7651 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
7652 },
7653 vitals: None,
7654 attributes: None,
7655 skills: None,
7656 inside_building: None,
7657 tile_id: None,
7658 presentation_state: None,
7659 sprite_mode: None,
7660 progression_xp: None,
7661 }],
7662 player: None,
7663 resource_nodes: vec![ResourceNodeView {
7664 id: "oak-1".into(),
7665 label: "Oak".into(),
7666 x: 130.0,
7667 y: 128.0,
7668 z: 0.0,
7669 item_template: "oak_log".into(),
7670 state: ResourceNodeState::Available,
7671 blocking: true,
7672 blocking_radius_m: 0.8,
7673 tile_id: None,
7674 sprite_mode: None,
7675 presentation_state: None,
7676 }],
7677 ground_drops: vec![],
7678 placed_containers: vec![],
7679 buildings: vec![BuildingView {
7680 id: "broker-hut".into(),
7681 label: "Broker".into(),
7682 x: 148.0,
7683 y: 118.0,
7684 width_m: 8.0,
7685 depth_m: 6.0,
7686 interior_blueprint: Some("broker_hut".into()),
7687 tags: vec![],
7688 }],
7689 doors: vec![flatland_protocol::DoorView {
7690 id: "door-1".into(),
7691 building_id: "broker-hut".into(),
7692 x: 148.0,
7693 y: 118.0,
7694 open: false,
7695 portal: Some("front".into()),
7696 }],
7697 interior_map: None,
7698 npcs: vec![],
7699 blueprints: vec![],
7700 world_width_m: 256.0,
7701 world_height_m: 256.0,
7702 terrain_zones: Vec::new(),
7703 z_platforms: Vec::new(),
7704 z_transitions: Vec::new(),
7705 world_clock: flatland_protocol::WorldClock::default(),
7706 inventory: std::collections::HashMap::new(),
7707 inventory_hints: std::collections::HashMap::new(),
7708 logs: VecDeque::new(),
7709 intents_sent: 0,
7710 ticks_received: 0,
7711 connected: true,
7712 disconnect_reason: None,
7713 show_stats: false,
7714 show_craft_menu: false,
7715 craft_menu_index: 0,
7716 craft_batch_quantity: 1,
7717 show_shop_menu: false,
7718 shop_catalog: None,
7719 shop_tab: ShopTab::default(),
7720 shop_menu_index: 0,
7721 shop_quantity: 1,
7722 shop_trade_log: VecDeque::new(),
7723 show_npc_verb_menu: false,
7724 npc_verb_target: None,
7725 npc_verb_index: 0,
7726 show_npc_chat: false,
7727 npc_chat: None,
7728 show_inventory_menu: false,
7729 inventory_menu_index: 0,
7730 show_move_picker: false,
7731 show_rename_prompt: false,
7732 show_worker_rename: false,
7733 rename_buffer: String::new(),
7734 move_picker_index: 0,
7735 move_picker: None,
7736 show_destroy_picker: false,
7737 destroy_confirm_pending: false,
7738 destroy_picker: None,
7739 combat_target: None,
7740 combat_target_label: None,
7741 in_combat: false,
7742 auto_attack: true,
7743 combat_has_los: false,
7744 attack_cd_ticks: 0,
7745 gcd_ticks: 0,
7746 weapon_ability_id: "unarmed".into(),
7747 mainhand_template_id: None,
7748 mainhand_label: None,
7749 worn: BTreeMap::new(),
7750 carry_mass: 0.0,
7751 carry_mass_max: 0.0,
7752 encumbrance: flatland_protocol::EncumbranceState::Light,
7753 inventory_stacks: Vec::new(),
7754 keychain_stacks: Vec::new(),
7755 combat_target_detail: None,
7756 cast_progress: None,
7757 ability_cooldowns: Vec::new(),
7758 blocking_active: false,
7759 max_target_slots: 1,
7760 combat_slots: Vec::new(),
7761 rotation_presets: Vec::new(),
7762 show_loadout_menu: false,
7763 show_keychain_menu: false,
7764 keychain_menu_index: 0,
7765 show_rotation_editor: false,
7766 loadout_menu_index: 0,
7767 rotation_editor: RotationEditorState::default(),
7768 harvest_in_progress: false,
7769 harvest_started_at: None,
7770 pending_craft_ack: None,
7771 pending_worker_job_ack: None,
7772 quest_log: Vec::new(),
7773 interactables: Vec::new(),
7774 show_quest_offer: false,
7775 pending_quest_offer: None,
7776 show_quest_menu: false,
7777 quest_menu_index: 0,
7778 quest_withdraw_confirm: false,
7779 hired_workers: Vec::new(),
7780 show_workers_menu: false,
7781 workers_menu_index: 0,
7782 workers_menu_compact: false,
7783 worker_step_display: BTreeMap::new(),
7784 show_worker_give_picker: false,
7785 worker_give_picker_index: 0,
7786 worker_give_picker: None,
7787 show_worker_give_target_picker: false,
7788 worker_give_target_picker_index: 0,
7789 worker_give_target_picker: None,
7790 show_worker_take_picker: false,
7791 worker_take_picker_index: 0,
7792 worker_take_picker: None,
7793 show_worker_teach_picker: false,
7794 worker_teach_picker_index: 0,
7795 worker_teach_picker: None,
7796 worker_route_editor: None,
7797 progression_curve: None,
7798 };
7799 state.player = state.entities.first().cloned();
7800 state
7801 }
7802
7803 #[test]
7804 fn probe_use_world_npc_beats_nearby_loot() {
7805 let mut state = sample_state();
7806 state.npcs.push(flatland_protocol::NpcView {
7807 id: "ada".into(),
7808 label: "Ada".into(),
7809 role: "broker".into(),
7810 x: 129.0,
7811 y: 128.0,
7812 building_id: None,
7813 entity_id: None,
7814 life_state: None,
7815 hp_pct: None,
7816 can_trade: true,
7817 tile_id: None,
7818 behavior_state: None,
7819 presentation_state: None,
7820 sprite_mode: None,
7821 });
7822 state.ground_drops.push(flatland_protocol::GroundDropView {
7823 id: "d1".into(),
7824 template_id: "lumber".into(),
7825 quantity: 1,
7826 x: 128.5,
7827 y: 128.0,
7828 z: 0.0,
7829 tile_id: None,
7830 });
7831 let probe = state.probe_use_world();
7832 let primary = probe.primary.expect("primary");
7833 assert_eq!(primary.kind, crate::UseWorldKind::Npc);
7834 assert_eq!(primary.id, "ada");
7835 }
7836
7837 #[test]
7838 fn probe_use_world_harvest_when_in_range() {
7839 let state = sample_state(); let probe = state.probe_use_world();
7841 assert!(
7842 probe.primary.is_none(),
7843 "oak is 2m away, out of harvest range"
7844 );
7845 assert!(probe
7846 .candidates
7847 .iter()
7848 .any(|c| c.kind == crate::UseWorldKind::Harvest));
7849
7850 let mut state = sample_state();
7851 state.resource_nodes[0].x = 129.0;
7852 let probe = state.probe_use_world();
7853 let primary = probe.primary.expect("primary");
7854 assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
7855 }
7856
7857 #[test]
7858 fn empty_entity_tick_preserves_welcome_snapshot() {
7859 let mut state = sample_state();
7860 state.inventory.insert("carrot".into(), 3);
7861 let delta = TickDelta {
7862 tick: 1,
7863 entities: vec![],
7864 resource_nodes: vec![],
7865 ground_drops: vec![],
7866 placed_containers: vec![],
7867 buildings: vec![],
7868 doors: vec![],
7869 interior_map: None,
7870 npcs: vec![],
7871 inventory: vec![],
7872 blueprints: vec![],
7873 world_clock: flatland_protocol::WorldClock::default(),
7874 combat: None,
7875 quest_log: vec![],
7876 hired_workers: Vec::new(),
7877 interactables: vec![],
7878 };
7879
7880 state.apply_tick_fields(&delta, 1);
7881
7882 assert_eq!(state.entities.len(), 1);
7883 assert!(state.player.is_some());
7884 assert_eq!(state.inventory.get("carrot"), Some(&3));
7885 assert_eq!(state.resource_nodes.len(), 1);
7886 }
7887
7888 #[test]
7889 fn tick_preserves_world_layers_when_delta_omits_them() {
7890 let mut state = sample_state();
7891 let delta = TickDelta {
7892 tick: 1,
7893 entities: state.entities.clone(),
7894 resource_nodes: vec![],
7895 ground_drops: vec![],
7896 placed_containers: vec![],
7897 buildings: vec![],
7898 doors: vec![],
7899 interior_map: None,
7900 npcs: vec![],
7901 inventory: vec![],
7902 blueprints: vec![],
7903 world_clock: flatland_protocol::WorldClock::default(),
7904 combat: None,
7905 quest_log: vec![],
7906 hired_workers: Vec::new(),
7907 interactables: vec![],
7908 };
7909
7910 state.apply_tick_fields(&delta, 1);
7911
7912 assert_eq!(state.resource_nodes.len(), 1);
7913 assert_eq!(state.buildings.len(), 1);
7914 assert_eq!(state.doors.len(), 1);
7915 }
7916
7917 #[test]
7918 fn tick_updates_resource_nodes_when_server_sends_them() {
7919 let mut state = sample_state();
7920 let delta = TickDelta {
7921 tick: 1,
7922 entities: state.entities.clone(),
7923 resource_nodes: vec![ResourceNodeView {
7924 id: "oak-1".into(),
7925 label: "Oak".into(),
7926 x: 130.0,
7927 y: 128.0,
7928 z: 0.0,
7929 item_template: "oak_log".into(),
7930 state: ResourceNodeState::Cooldown,
7931 blocking: true,
7932 blocking_radius_m: 0.8,
7933 tile_id: None,
7934 sprite_mode: None,
7935 presentation_state: None,
7936 }],
7937 buildings: vec![],
7938 doors: vec![],
7939 interior_map: None,
7940 npcs: vec![],
7941 inventory: vec![],
7942 blueprints: vec![],
7943 world_clock: flatland_protocol::WorldClock::default(),
7944 ground_drops: vec![],
7945 placed_containers: vec![],
7946 combat: None,
7947 quest_log: vec![],
7948 hired_workers: Vec::new(),
7949 interactables: vec![],
7950 };
7951
7952 state.apply_tick_fields(&delta, 1);
7953
7954 assert!(matches!(
7955 state.resource_nodes[0].state,
7956 ResourceNodeState::Cooldown
7957 ));
7958 }
7959
7960 #[test]
7961 fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
7962 let mut state = GameState {
7963 session_id: 1,
7964 entity_id: 1,
7965 character_id: None,
7966 tick: 0,
7967 chunk_rev: 0,
7968 content_rev: 0,
7969 publish_rev: 0,
7970 entities: vec![EntityState {
7971 id: 1,
7972 label: "You".into(),
7973 transform: Transform {
7974 position: WorldCoord::surface(4.5, 2.0),
7975 yaw: 0.0,
7976 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
7977 },
7978 vitals: None,
7979 attributes: None,
7980 skills: None,
7981 inside_building: Some("broker_hut".into()),
7982 tile_id: None,
7983 presentation_state: None,
7984 sprite_mode: None,
7985 progression_xp: None,
7986 }],
7987 player: None,
7988 resource_nodes: vec![],
7989 ground_drops: vec![],
7990 placed_containers: vec![],
7991 buildings: vec![BuildingView {
7992 id: "broker_hut".into(),
7993 label: "Broker".into(),
7994 x: 158.0,
7995 y: 124.0,
7996 width_m: 8.0,
7997 depth_m: 6.0,
7998 interior_blueprint: Some("broker_hut".into()),
7999 tags: vec![],
8000 }],
8001 doors: vec![flatland_protocol::DoorView {
8002 id: "broker_hut_exit".into(),
8003 building_id: "broker_hut".into(),
8004 x: 4.3,
8005 y: 0.9,
8006 open: true,
8007 portal: Some("front".into()),
8008 }],
8009 interior_map: None,
8010 npcs: vec![flatland_protocol::NpcView {
8011 id: "ada_broker".into(),
8012 label: "Ada".into(),
8013 x: 4.5,
8014 y: 2.0,
8015 building_id: Some("broker_hut".into()),
8016 role: "broker".into(),
8017 entity_id: None,
8018 life_state: None,
8019 hp_pct: None,
8020 can_trade: true,
8021 tile_id: None,
8022 behavior_state: None,
8023 presentation_state: None,
8024 sprite_mode: None,
8025 }],
8026 blueprints: vec![],
8027 world_width_m: 256.0,
8028 world_height_m: 256.0,
8029 terrain_zones: Vec::new(),
8030 z_platforms: Vec::new(),
8031 z_transitions: Vec::new(),
8032 world_clock: flatland_protocol::WorldClock::default(),
8033 inventory: std::collections::HashMap::new(),
8034 inventory_hints: std::collections::HashMap::new(),
8035 logs: VecDeque::new(),
8036 intents_sent: 0,
8037 ticks_received: 0,
8038 connected: true,
8039 disconnect_reason: None,
8040 show_stats: false,
8041 show_craft_menu: false,
8042 craft_menu_index: 0,
8043 craft_batch_quantity: 1,
8044 show_shop_menu: false,
8045 shop_catalog: None,
8046 shop_tab: ShopTab::default(),
8047 shop_menu_index: 0,
8048 shop_quantity: 1,
8049 shop_trade_log: VecDeque::new(),
8050 show_npc_verb_menu: false,
8051 npc_verb_target: None,
8052 npc_verb_index: 0,
8053 show_npc_chat: false,
8054 npc_chat: None,
8055 show_inventory_menu: false,
8056 inventory_menu_index: 0,
8057 show_move_picker: false,
8058 show_rename_prompt: false,
8059 show_worker_rename: false,
8060 rename_buffer: String::new(),
8061 move_picker_index: 0,
8062 move_picker: None,
8063 show_destroy_picker: false,
8064 destroy_confirm_pending: false,
8065 destroy_picker: None,
8066 combat_target: None,
8067 combat_target_label: None,
8068 in_combat: false,
8069 auto_attack: true,
8070 combat_has_los: false,
8071 attack_cd_ticks: 0,
8072 gcd_ticks: 0,
8073 weapon_ability_id: "unarmed".into(),
8074 mainhand_template_id: None,
8075 mainhand_label: None,
8076 worn: BTreeMap::new(),
8077 carry_mass: 0.0,
8078 carry_mass_max: 0.0,
8079 encumbrance: flatland_protocol::EncumbranceState::Light,
8080 inventory_stacks: Vec::new(),
8081 keychain_stacks: Vec::new(),
8082 combat_target_detail: None,
8083 cast_progress: None,
8084 ability_cooldowns: Vec::new(),
8085 blocking_active: false,
8086 max_target_slots: 1,
8087 combat_slots: Vec::new(),
8088 rotation_presets: Vec::new(),
8089 show_loadout_menu: false,
8090 show_keychain_menu: false,
8091 keychain_menu_index: 0,
8092 show_rotation_editor: false,
8093 loadout_menu_index: 0,
8094 rotation_editor: RotationEditorState::default(),
8095 harvest_in_progress: false,
8096 harvest_started_at: None,
8097 pending_craft_ack: None,
8098 pending_worker_job_ack: None,
8099 quest_log: Vec::new(),
8100 interactables: Vec::new(),
8101 show_quest_offer: false,
8102 pending_quest_offer: None,
8103 show_quest_menu: false,
8104 quest_menu_index: 0,
8105 quest_withdraw_confirm: false,
8106 hired_workers: Vec::new(),
8107 show_workers_menu: false,
8108 workers_menu_index: 0,
8109 workers_menu_compact: false,
8110 worker_step_display: BTreeMap::new(),
8111 show_worker_give_picker: false,
8112 worker_give_picker_index: 0,
8113 worker_give_picker: None,
8114 show_worker_give_target_picker: false,
8115 worker_give_target_picker_index: 0,
8116 worker_give_target_picker: None,
8117 show_worker_take_picker: false,
8118 worker_take_picker_index: 0,
8119 worker_take_picker: None,
8120 show_worker_teach_picker: false,
8121 worker_teach_picker_index: 0,
8122 worker_teach_picker: None,
8123 worker_route_editor: None,
8124 progression_curve: None,
8125 };
8126 state.player = state.entities.first().cloned();
8127 assert_eq!(
8128 state.nearest_interact_target().as_deref(),
8129 Some("ada_broker")
8130 );
8131 }
8132
8133 #[test]
8134 fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
8135 let mut state = sample_state();
8136 state.placed_containers = vec![
8139 flatland_protocol::PlacedContainerView {
8140 id: "near".into(),
8141 template_id: "wooden_chest_small".into(),
8142 display_name: "Wooden Chest".into(),
8143 x: 130.0,
8144 y: 128.0,
8145 z: 0.0,
8146 locked: true,
8147 accessible: true,
8148 owner_character_id: None,
8149 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
8150 lock_id: None,
8151 capacity_volume: None,
8152 item_instance_id: Some(uuid::Uuid::from_u128(1)),
8153 tile_id: None,
8154 worker_lodging_capacity: None,
8155 },
8156 flatland_protocol::PlacedContainerView {
8157 id: "far".into(),
8158 template_id: "wooden_chest_small".into(),
8159 display_name: "Distant Chest".into(),
8160 x: 128.0 + CONTAINER_RANGE_M + 5.0,
8161 y: 128.0,
8162 z: 0.0,
8163 locked: false,
8164 accessible: true,
8165 owner_character_id: None,
8166 contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
8167 lock_id: None,
8168 capacity_volume: None,
8169 item_instance_id: Some(uuid::Uuid::from_u128(2)),
8170 tile_id: None,
8171 worker_lodging_capacity: None,
8172 },
8173 ];
8174
8175 let nearby = state.nearby_containers();
8176 assert_eq!(
8177 nearby.len(),
8178 1,
8179 "far chest must not appear once out of range"
8180 );
8181 assert_eq!(nearby[0].view.id, "near");
8182 assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
8183 assert!(nearby[0].rows[0].is_chest_shell);
8184
8185 state.placed_containers[0].accessible = false;
8188 let nearby = state.nearby_containers();
8189 assert_eq!(nearby.len(), 1);
8190 assert_eq!(nearby[0].rows.len(), 1);
8191 assert!(nearby[0].rows[0].is_chest_shell);
8192 }
8193
8194 #[test]
8195 fn chest_pickup_destinations_offer_person_and_worn_bag() {
8196 let mut state = sample_state();
8197 let back_id = uuid::Uuid::from_u128(42);
8198 state.worn.insert(
8199 BodySlot::Back,
8200 flatland_protocol::ItemStack {
8201 template_id: "travel_backpack".into(),
8202 quantity: 1,
8203 item_instance_id: Some(back_id),
8204 props: Default::default(),
8205 contents: Vec::new(),
8206 display_name: Some("Travel Backpack".into()),
8207 category: Some("container".into()),
8208 base_mass: Some(2.5),
8209 base_volume: Some(12.0),
8210 capacity_volume: Some(80.0),
8211 stackable: Some(false),
8212 world_placeable: Some(false),
8213 worker_lodging_capacity: None,
8214 },
8215 );
8216 let opts = state.chest_pickup_destinations("chest-1");
8217 assert!(opts.iter().any(|o| matches!(
8218 &o.kind,
8219 MoveOptionKind::PickupPlaced {
8220 nest_parent_instance_id: None,
8221 ..
8222 }
8223 )));
8224 assert!(opts.iter().any(|o| matches!(
8225 &o.kind,
8226 MoveOptionKind::PickupPlaced {
8227 nest_parent_instance_id: Some(id),
8228 ..
8229 } if *id == back_id
8230 )));
8231 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
8232 }
8233
8234 #[test]
8235 fn placed_container_public_label_hides_owner_custom_name() {
8236 let owner = uuid::Uuid::from_u128(99);
8237 let mut state = sample_state();
8238 state.character_id = Some(uuid::Uuid::from_u128(1));
8239 state.inventory_hints.insert(
8240 "wooden_chest_medium".into(),
8241 InventoryHint {
8242 display_name: "Medium Wooden Chest".into(),
8243 category: "container".into(),
8244 base_mass: None,
8245 base_volume: None,
8246 capacity_volume: None,
8247 stackable: false,
8248 },
8249 );
8250 let chest = flatland_protocol::PlacedContainerView {
8251 id: "c1".into(),
8252 template_id: "wooden_chest_medium".into(),
8253 display_name: "Barry's Loot #a3f2".into(),
8254 x: 128.0,
8255 y: 128.0,
8256 z: 0.0,
8257 locked: false,
8258 accessible: true,
8259 owner_character_id: Some(owner),
8260 contents: vec![],
8261 lock_id: None,
8262 capacity_volume: None,
8263 item_instance_id: None,
8264 tile_id: None,
8265 worker_lodging_capacity: None,
8266 };
8267 assert_eq!(
8268 state.placed_container_public_label(&chest),
8269 "Medium Wooden Chest"
8270 );
8271 state.character_id = Some(owner);
8272 assert_eq!(
8273 state.placed_container_public_label(&chest),
8274 "Barry's Loot #a3f2"
8275 );
8276 }
8277
8278 #[test]
8279 fn location_context_lists_nearby_resource_node() {
8280 let mut state = sample_state();
8281 state.player = state.entities.first().cloned();
8282 state.resource_nodes[0].x = 128.2;
8283 state.resource_nodes[0].y = 128.0;
8284 let lines = state.location_context_lines();
8285 assert!(
8286 lines
8287 .iter()
8288 .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
8289 "expected resource node in context: {:?}",
8290 lines
8291 );
8292 }
8293
8294 #[test]
8295 fn quest_board_usable_within_board_radius() {
8296 let mut state = sample_state();
8297 state.player = state.entities.first().cloned();
8298 state.interactables = vec![flatland_protocol::InteractableView {
8299 id: "board-1".into(),
8300 kind: "quest_board".into(),
8301 label: "Town Quest Board".into(),
8302 x: 130.5,
8303 y: 128.0,
8304 z: 0.0,
8305 board_id: Some("starter_town_board".into()),
8306 }];
8307 assert_eq!(
8309 state.nearest_interact_target().as_deref(),
8310 Some("board-1"),
8311 "quest board should be selectable at ~2.5m"
8312 );
8313 let lines = state.location_context_lines();
8314 assert!(
8315 lines
8316 .iter()
8317 .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
8318 "HUD should advertise f when board is in range: {:?}",
8319 lines
8320 );
8321 }
8322
8323 #[test]
8324 fn inventory_selectable_rows_orders_worn_before_person_before_nearby() {
8325 let mut state = sample_state();
8326 state.worn.insert(
8327 BodySlot::Back,
8328 flatland_protocol::ItemStack {
8329 template_id: "travel_backpack".into(),
8330 quantity: 1,
8331 item_instance_id: Some(uuid::Uuid::from_u128(3)),
8332 props: Default::default(),
8333 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
8334 display_name: None,
8335 category: None,
8336 base_mass: None,
8337 base_volume: None,
8338 capacity_volume: None,
8339 stackable: None,
8340 world_placeable: None,
8341 worker_lodging_capacity: None,
8342 },
8343 );
8344 state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
8345 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
8346 id: "chest-1".into(),
8347 template_id: "wooden_chest_small".into(),
8348 display_name: "Wooden Chest".into(),
8349 x: 129.0,
8350 y: 128.0,
8351 z: 0.0,
8352 locked: false,
8353 accessible: true,
8354 owner_character_id: None,
8355 contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
8356 lock_id: None,
8357 capacity_volume: None,
8358 item_instance_id: Some(uuid::Uuid::from_u128(4)),
8359 tile_id: None,
8360 worker_lodging_capacity: None,
8361 }];
8362
8363 let rows = state.inventory_selectable_rows();
8364 let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
8365 assert_eq!(
8366 sections,
8367 vec![
8368 InventorySection::Worn, InventorySection::Worn, InventorySection::Person, InventorySection::Nearby, InventorySection::Nearby, ]
8374 );
8375 assert_eq!(rows[0].stack.template_id, "travel_backpack");
8376 assert!(rows[0].is_equip_shell);
8377 assert_eq!(rows[1].stack.template_id, "iron_ore");
8378 assert_eq!(rows[1].depth, 1);
8379 assert_eq!(rows[2].stack.template_id, "lumber");
8380 assert!(rows[3].is_chest_shell);
8381 assert_eq!(rows[4].stack.template_id, "wood_axe");
8382 assert_eq!(rows[4].depth, 1);
8383
8384 let lines = state.inventory_browser_lines();
8385 assert!(lines.iter().any(|l| matches!(
8386 l,
8387 InventoryBrowserLine::Section(s) if s.contains("Worn")
8388 )));
8389 assert!(lines.iter().any(|l| matches!(
8390 l,
8391 InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
8392 || text.contains("backpack")
8393 )));
8394 }
8395
8396 #[test]
8397 fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
8398 let mut state = sample_state();
8399 let back_id = uuid::Uuid::from_u128(5);
8400 state.worn.insert(
8401 BodySlot::Back,
8402 flatland_protocol::ItemStack {
8403 template_id: "travel_backpack".into(),
8404 quantity: 1,
8405 item_instance_id: Some(back_id),
8406 props: Default::default(),
8407 contents: Vec::new(),
8408 display_name: None,
8409 category: Some("container".into()),
8410 base_mass: None,
8411 base_volume: None,
8412 capacity_volume: Some(80.0),
8413 stackable: None,
8414 world_placeable: None,
8415 worker_lodging_capacity: None,
8416 },
8417 );
8418 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
8419 id: "chest-1".into(),
8420 template_id: "wooden_chest_small".into(),
8421 display_name: "Wooden Chest".into(),
8422 x: 129.0,
8423 y: 128.0,
8424 z: 0.0,
8425 locked: false,
8426 accessible: true,
8427 owner_character_id: None,
8428 contents: Vec::new(),
8429 lock_id: None,
8430 capacity_volume: None,
8431 item_instance_id: Some(uuid::Uuid::from_u128(6)),
8432 tile_id: None,
8433 worker_lodging_capacity: None,
8434 }];
8435
8436 let opts = state.move_destinations_for(
8439 &flatland_protocol::InventoryLocation::Root,
8440 None,
8441 None,
8442 "lumber",
8443 );
8444 assert!(!opts.iter().any(|o| matches!(
8445 &o.kind,
8446 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
8447 )));
8448 assert!(opts.iter().any(|o| matches!(
8449 &o.kind,
8450 MoveOptionKind::Move { location, parent_instance_id, .. }
8451 if *location == flatland_protocol::InventoryLocation::Worn {
8452 slot: BodySlot::Back,
8453 } && *parent_instance_id == Some(back_id)
8454 )));
8455 assert!(opts.iter().any(|o| matches!(
8456 &o.kind,
8457 MoveOptionKind::Move { location, .. }
8458 if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
8459 )));
8460 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
8461 assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
8462
8463 let from_backpack = flatland_protocol::InventoryLocation::Worn {
8467 slot: BodySlot::Back,
8468 };
8469 let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
8470 assert!(!opts.iter().any(|o| matches!(
8471 &o.kind,
8472 MoveOptionKind::Move { location, parent_instance_id, .. }
8473 if *location == from_backpack && *parent_instance_id == Some(back_id)
8474 )));
8475 assert!(opts.iter().any(|o| matches!(
8476 &o.kind,
8477 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
8478 )));
8479 }
8480
8481 #[test]
8482 fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
8483 let mut state = sample_state();
8484 state.worn.insert(
8487 BodySlot::Waist,
8488 flatland_protocol::ItemStack {
8489 template_id: "simple_belt".into(),
8490 quantity: 1,
8491 item_instance_id: Some(uuid::Uuid::from_u128(10)),
8492 props: Default::default(),
8493 contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
8494 display_name: None,
8495 category: Some("container".into()),
8496 base_mass: None,
8497 base_volume: None,
8498 capacity_volume: None,
8499 stackable: None,
8500 world_placeable: None,
8501 worker_lodging_capacity: None,
8502 },
8503 );
8504 state.worn.insert(
8505 BodySlot::Head,
8506 flatland_protocol::ItemStack {
8507 template_id: "cloth_cap".into(),
8508 quantity: 1,
8509 item_instance_id: Some(uuid::Uuid::from_u128(11)),
8510 props: Default::default(),
8511 contents: Vec::new(),
8512 display_name: None,
8513 category: Some("armor".into()),
8514 base_mass: None,
8515 base_volume: None,
8516 capacity_volume: None,
8517 stackable: None,
8518 world_placeable: None,
8519 worker_lodging_capacity: None,
8520 },
8521 );
8522 state.worn.insert(
8523 BodySlot::Back,
8524 flatland_protocol::ItemStack {
8525 template_id: "travel_backpack".into(),
8526 quantity: 1,
8527 item_instance_id: Some(uuid::Uuid::from_u128(12)),
8528 props: Default::default(),
8529 contents: Vec::new(),
8530 display_name: None,
8531 category: Some("container".into()),
8532 base_mass: None,
8533 base_volume: None,
8534 capacity_volume: None,
8535 stackable: None,
8536 world_placeable: None,
8537 worker_lodging_capacity: None,
8538 },
8539 );
8540
8541 let rows = state.worn_rows();
8542 assert_eq!(rows.len(), 4);
8544 assert_eq!(rows[0].stack.template_id, "cloth_cap");
8545 assert!(rows[0].is_equip_shell);
8546 assert_eq!(rows[1].stack.template_id, "travel_backpack");
8547 assert!(rows[1].is_equip_shell);
8548 assert_eq!(rows[2].stack.template_id, "simple_belt");
8549 assert!(rows[2].is_equip_shell);
8550 assert_eq!(rows[3].stack.template_id, "leather_pouch");
8551 assert_eq!(rows[3].depth, 1);
8552 assert!(!rows[3].is_equip_shell);
8553 }
8554
8555 #[test]
8556 fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
8557 let mut state = sample_state();
8558 state.worn.insert(
8559 BodySlot::Waist,
8560 flatland_protocol::ItemStack {
8561 template_id: "simple_belt".into(),
8562 quantity: 1,
8563 item_instance_id: Some(uuid::Uuid::from_u128(20)),
8564 props: Default::default(),
8565 contents: Vec::new(),
8566 display_name: Some("Simple Belt".into()),
8567 category: Some("container".into()),
8568 base_mass: None,
8569 base_volume: None,
8570 capacity_volume: None,
8571 stackable: None,
8572 world_placeable: None,
8573 worker_lodging_capacity: None,
8574 },
8575 );
8576 state.worn.insert(
8577 BodySlot::Head,
8578 flatland_protocol::ItemStack {
8579 template_id: "cloth_cap".into(),
8580 quantity: 1,
8581 item_instance_id: Some(uuid::Uuid::from_u128(21)),
8582 props: Default::default(),
8583 contents: Vec::new(),
8584 display_name: Some("Cloth Cap".into()),
8585 category: Some("armor".into()),
8586 base_mass: None,
8587 base_volume: None,
8588 capacity_volume: None,
8589 stackable: None,
8590 world_placeable: None,
8591 worker_lodging_capacity: None,
8592 },
8593 );
8594
8595 let opts = state.move_destinations_for(
8596 &flatland_protocol::InventoryLocation::Root,
8597 None,
8598 None,
8599 "leather_pouch",
8600 );
8601 assert!(
8602 opts.iter().any(|o| matches!(
8603 &o.kind,
8604 MoveOptionKind::Move { location, .. }
8605 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
8606 )),
8607 "belt loop must be offered when moving a pouch"
8608 );
8609 assert!(
8610 !opts.iter().any(|o| matches!(
8611 &o.kind,
8612 MoveOptionKind::Move { location, .. }
8613 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
8614 )),
8615 "armor slots can't hold other items and must not appear as move destinations"
8616 );
8617 let belt_opt = opts
8618 .iter()
8619 .find(|o| matches!(
8620 &o.kind,
8621 MoveOptionKind::Move { location, .. }
8622 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
8623 ))
8624 .unwrap();
8625 assert!(belt_opt.label.contains("belt loop"));
8626
8627 let opts = state.move_destinations_for(
8628 &flatland_protocol::InventoryLocation::Root,
8629 None,
8630 None,
8631 "lumber",
8632 );
8633 assert!(
8634 !opts.iter().any(|o| o.label.contains("belt loop")),
8635 "loose materials must not target the belt shell — only nested pouches"
8636 );
8637 }
8638
8639 #[test]
8640 fn move_destinations_for_offers_dimensional_pouch_on_belt() {
8641 let mut state = sample_state();
8642 let belt_id = uuid::Uuid::from_u128(30);
8643 let pouch_id = uuid::Uuid::from_u128(31);
8644 state.worn.insert(
8645 BodySlot::Waist,
8646 flatland_protocol::ItemStack {
8647 template_id: "simple_belt".into(),
8648 quantity: 1,
8649 item_instance_id: Some(belt_id),
8650 props: Default::default(),
8651 world_placeable: None,
8652 worker_lodging_capacity: None,
8653 contents: vec![flatland_protocol::ItemStack {
8654 template_id: "dimensional_pouch".into(),
8655 quantity: 1,
8656 item_instance_id: Some(pouch_id),
8657 props: Default::default(),
8658 contents: Vec::new(),
8659 display_name: Some("Dimensional Pouch".into()),
8660 category: Some("container".into()),
8661 base_mass: None,
8662 base_volume: None,
8663 capacity_volume: Some(200.0),
8664 stackable: None,
8665 world_placeable: None,
8666 worker_lodging_capacity: None,
8667 }],
8668 display_name: Some("Simple Belt".into()),
8669 category: Some("container".into()),
8670 base_mass: None,
8671 base_volume: None,
8672 capacity_volume: None,
8673 stackable: None,
8674 },
8675 );
8676
8677 let opts = state.move_destinations_for(
8678 &flatland_protocol::InventoryLocation::Root,
8679 None,
8680 None,
8681 "iron_ore",
8682 );
8683 assert!(
8684 opts.iter().any(|o| matches!(
8685 &o.kind,
8686 MoveOptionKind::Move {
8687 location,
8688 parent_instance_id,
8689 ..
8690 } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
8691 && *parent_instance_id == Some(pouch_id)
8692 )),
8693 "dimensional pouch clipped on belt must accept loose items"
8694 );
8695 assert!(
8696 opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
8697 "destination label should name the pouch"
8698 );
8699 }
8700
8701 #[test]
8702 fn container_volume_label_on_placed_chest_shell() {
8703 let mut state = sample_state();
8704 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
8705 id: "chest-1".into(),
8706 template_id: "wooden_chest_small".into(),
8707 display_name: "Camp Chest".into(),
8708 worker_lodging_capacity: None,
8709 x: 129.0,
8710 y: 128.0,
8711 z: 0.0,
8712 locked: false,
8713 accessible: true,
8714 owner_character_id: None,
8715 contents: vec![flatland_protocol::ItemStack {
8716 template_id: "iron_ore".into(),
8717 quantity: 2,
8718 item_instance_id: None,
8719 props: Default::default(),
8720 contents: Vec::new(),
8721 display_name: None,
8722 category: None,
8723 base_mass: None,
8724 base_volume: Some(2.0),
8725 capacity_volume: None,
8726 stackable: None,
8727 world_placeable: None,
8728 worker_lodging_capacity: None,
8729 }],
8730 lock_id: None,
8731 capacity_volume: Some(60.0),
8732 item_instance_id: Some(uuid::Uuid::from_u128(4)),
8733 tile_id: None,
8734 }];
8735 let nearby = state.nearby_containers();
8736 let label = state.container_volume_label(&nearby[0].rows[0]);
8737 assert!(
8738 label.contains("vol 4/60"),
8739 "expected used/cap in label, got {label}"
8740 );
8741 assert!(
8742 label.contains("56 free"),
8743 "expected free space, got {label}"
8744 );
8745 }
8746
8747 #[test]
8748 fn key_pair_chest_label_from_placed_lock_id() {
8749 let mut state = sample_state();
8750 let owner = uuid::Uuid::from_u128(77);
8751 state.character_id = Some(owner);
8752 let lock = uuid::Uuid::from_u128(99).to_string();
8753 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
8754 id: "chest-1".into(),
8755 template_id: "wooden_chest_small".into(),
8756 display_name: "Barry's Loot #a3f2".into(),
8757 x: 129.0,
8758 y: 128.0,
8759 z: 0.0,
8760 locked: true,
8761 accessible: true,
8762 owner_character_id: Some(owner),
8763 contents: Vec::new(),
8764 lock_id: Some(lock.clone()),
8765 capacity_volume: None,
8766 item_instance_id: Some(uuid::Uuid::from_u128(4)),
8767 tile_id: None,
8768 worker_lodging_capacity: None,
8769 }];
8770 let key_id = uuid::Uuid::from_u128(5);
8771 let key = flatland_protocol::ItemStack {
8772 template_id: KEY_TEMPLATE.into(),
8773 quantity: 1,
8774 item_instance_id: Some(key_id),
8775 props: BTreeMap::from([
8776 (PROP_OPENS_LOCK_ID.into(), lock),
8777 (
8778 PROP_OPENS_CONTAINER_NAME.into(),
8779 "Barry's Loot #a3f2".into(),
8780 ),
8781 ]),
8782 contents: Vec::new(),
8783 display_name: Some("Container Key".into()),
8784 category: Some("key".into()),
8785 base_mass: None,
8786 base_volume: None,
8787 capacity_volume: None,
8788 stackable: None,
8789 world_placeable: None,
8790 worker_lodging_capacity: None,
8791 };
8792 state.inventory_stacks = vec![key.clone()];
8793 assert_eq!(
8794 state.key_pair_chest_label(&key).as_deref(),
8795 Some("Barry's Loot #a3f2")
8796 );
8797 assert!(state.key_drop_blocked(&key));
8798 }
8799
8800 #[test]
8801 fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
8802 let mut state = sample_state();
8803 let lock = uuid::Uuid::from_u128(101).to_string();
8804 let key = flatland_protocol::ItemStack {
8805 template_id: KEY_TEMPLATE.into(),
8806 quantity: 1,
8807 item_instance_id: Some(uuid::Uuid::from_u128(7)),
8808 props: BTreeMap::from([
8809 (PROP_OPENS_LOCK_ID.into(), lock),
8810 (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
8811 ]),
8812 contents: Vec::new(),
8813 display_name: None,
8814 category: Some("key".into()),
8815 base_mass: None,
8816 base_volume: None,
8817 capacity_volume: None,
8818 stackable: None,
8819 world_placeable: None,
8820 worker_lodging_capacity: None,
8821 };
8822 state.placed_containers.clear();
8823 assert_eq!(
8824 state.key_pair_chest_label(&key).as_deref(),
8825 Some("Camp Stash")
8826 );
8827 }
8828
8829 #[test]
8830 fn key_drop_allowed_when_paired_chest_unlocked() {
8831 let mut state = sample_state();
8832 let lock = uuid::Uuid::from_u128(100).to_string();
8833 let key_id = uuid::Uuid::from_u128(6);
8834 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
8835 id: "chest-1".into(),
8836 template_id: "wooden_chest_small".into(),
8837 display_name: "Camp Chest".into(),
8838 x: 129.0,
8839 y: 128.0,
8840 z: 0.0,
8841 locked: false,
8842 accessible: true,
8843 owner_character_id: None,
8844 contents: Vec::new(),
8845 lock_id: Some(lock.clone()),
8846 capacity_volume: None,
8847 item_instance_id: None,
8848 tile_id: None,
8849 worker_lodging_capacity: None,
8850 }];
8851 let key = flatland_protocol::ItemStack {
8852 template_id: KEY_TEMPLATE.into(),
8853 quantity: 1,
8854 item_instance_id: Some(key_id),
8855 props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
8856 contents: Vec::new(),
8857 display_name: None,
8858 category: Some("key".into()),
8859 base_mass: None,
8860 base_volume: None,
8861 capacity_volume: None,
8862 stackable: None,
8863 world_placeable: None,
8864 worker_lodging_capacity: None,
8865 };
8866 state.inventory_stacks = vec![key.clone()];
8867 assert!(!state.key_drop_blocked(&key));
8868 let opts = state.move_destinations_for(
8869 &flatland_protocol::InventoryLocation::Root,
8870 None,
8871 Some(key_id),
8872 KEY_TEMPLATE,
8873 );
8874 assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
8875 }
8876
8877 #[test]
8878 fn combat_hud_refreshes_progression_xp_when_entity_stale() {
8879 use flatland_protocol::{CombatHud, ProgressionXp, ProgressionCurve};
8880
8881 let mut state = sample_state();
8882 let curve = ProgressionCurve::default();
8883 let bootstrap = ProgressionXp::bootstrap_new(
8884 curve.baseline_display,
8885 curve.xp_base,
8886 curve.xp_growth,
8887 );
8888 let mut fresh = bootstrap.clone();
8889 fresh.strength += 0.08;
8890 if let Some(player) = state.player.as_mut() {
8891 player.progression_xp = Some(bootstrap);
8892 }
8893
8894 let combat = CombatHud {
8895 progression_xp: Some(fresh.clone()),
8896 progression_baseline: curve.baseline_display,
8897 progression_xp_base: curve.xp_base,
8898 progression_xp_growth: curve.xp_growth,
8899 attributes: state.player.as_ref().and_then(|p| p.attributes),
8900 skills: state.player.as_ref().and_then(|p| p.skills.clone()),
8901 ..CombatHud::default()
8902 };
8903 state.apply_combat_hud(&combat);
8904
8905 let xp = state
8906 .player
8907 .as_ref()
8908 .and_then(|p| p.progression_xp.as_ref())
8909 .expect("xp");
8910 assert!((xp.strength - fresh.strength).abs() < 0.001);
8911 assert!(state.progression_curve.is_some());
8912 }
8913
8914 #[test]
8915 fn loose_consumable_move_picker_offers_use_and_storage() {
8916 let mut state = sample_state();
8917 let inst = uuid::Uuid::from_u128(77);
8918 state.inventory_stacks = vec![flatland_protocol::ItemStack {
8919 template_id: "carrot".into(),
8920 quantity: 2,
8921 item_instance_id: Some(inst),
8922 props: Default::default(),
8923 contents: Vec::new(),
8924 display_name: Some("Wild Carrot".into()),
8925 category: Some("consumable".into()),
8926 base_mass: None,
8927 base_volume: None,
8928 capacity_volume: None,
8929 stackable: Some(true),
8930 world_placeable: None,
8931 worker_lodging_capacity: None,
8932 }];
8933 state.inventory_hints.insert(
8934 "carrot".into(),
8935 InventoryHint {
8936 display_name: "Wild Carrot".into(),
8937 category: "consumable".into(),
8938 base_mass: Some(0.15),
8939 base_volume: Some(0.3),
8940 capacity_volume: None,
8941 stackable: true,
8942 },
8943 );
8944 state.show_inventory_menu = true;
8945 state.inventory_menu_index = 0;
8946
8947 let row = state.inventory_selected_row().expect("carrot row");
8948 let mut options = state.move_destinations_for(
8949 &row.from,
8950 row.from_parent_instance_id,
8951 row.stack.item_instance_id,
8952 &row.stack.template_id,
8953 );
8954 if row.from == flatland_protocol::InventoryLocation::Root
8955 && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
8956 {
8957 options.insert(
8958 0,
8959 MoveOption {
8960 label: "Use (eat / drink)".into(),
8961 kind: MoveOptionKind::Use,
8962 },
8963 );
8964 }
8965
8966 assert_eq!(options.first().map(|o| &o.label), Some(&"Use (eat / drink)".into()));
8967 assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
8968 assert!(options.iter().any(|o| matches!(o.kind, MoveOptionKind::Drop)));
8969 }
8970}