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