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