1use std::collections::{BTreeMap, VecDeque};
2use std::time::{Duration, Instant};
3
4use flatland_protocol::{
5 AbilityCooldownHud, BlueprintView, BodySlot, BuildingView, CastProgressHud, CombatHud,
6 CombatSlotHud, CombatTargetHud,
7 DoorView, EntityId, EntityState, Intent, InteriorMapView, LifeState, NpcView, RotationPreset,
8 Seq, SessionId, TerrainKindView, TerrainZoneView, Tick, ZPlatformView, ZTransitionView,
9};
10
11use crate::session::{PlayConnection, SessionEvent};
12
13const KEY_TEMPLATE: &str = "container_key";
15const PROP_LOCK_ID: &str = "lock_id";
16const PROP_OPENS_LOCK_ID: &str = "opens_lock_id";
17const PROP_OPENS_CONTAINER_NAME: &str = "opens_container_name";
18const PROP_CUSTOM_NAME: &str = "custom_name";
19const PROP_LOCKED: &str = "locked";
20
21fn stack_is_locked(stack: &flatland_protocol::ItemStack) -> bool {
22 stack
23 .props
24 .get(PROP_LOCKED)
25 .is_some_and(|v| v == "true" || v == "1")
26}
27
28const MAX_LOG_LINES: usize = 200;
29const MAX_SHOP_TRADE_LOG_LINES: usize = 40;
30const INTERACTION_RADIUS_M: f32 = 1.5;
31const DOOR_INTERACTION_RADIUS_M: f32 = 3.5;
32const QUEST_BOARD_INTERACTION_RADIUS_M: f32 = 3.0;
33const HARVEST_CLIENT_TIMEOUT: Duration = Duration::from_secs(12);
34const CRAFT_STAMINA_COST: f32 = 3.0;
36
37#[derive(Debug, Clone, Default)]
39pub struct InventoryHint {
40 pub display_name: String,
41 pub category: String,
42 pub base_mass: Option<f32>,
43 pub base_volume: Option<f32>,
44 pub capacity_volume: Option<f32>,
45 pub stackable: bool,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub enum RotationEditorMode {
51 #[default]
52 List,
53 EditSequence,
54 PickAbility,
55 EditLabel,
56}
57
58#[derive(Debug, Clone, Default)]
60pub struct RotationEditorState {
61 pub mode: RotationEditorMode,
62 pub list_index: usize,
63 pub ability_index: usize,
64 pub picker_index: usize,
65 pub draft: Option<RotationPreset>,
66 pub label_buffer: String,
67}
68
69impl RotationEditorState {
70 pub fn reset(&mut self) {
71 *self = Self::default();
72 }
73}
74
75pub const CONTAINER_RANGE_M: f32 = 3.0;
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum InventorySection {
85 Worn,
87 Person,
89 Nearby,
91}
92
93pub fn body_slot_label(slot: BodySlot) -> &'static str {
96 match slot {
97 BodySlot::Head => "Head",
98 BodySlot::Body => "Body",
99 BodySlot::Arms => "Arms",
100 BodySlot::Legs => "Legs",
101 BodySlot::Feet => "Feet",
102 BodySlot::Back => "Back",
103 BodySlot::Waist => "Waist",
104 }
105}
106
107fn guess_body_slot(template_id: &str) -> Option<BodySlot> {
113 if template_id.contains("backpack") {
114 Some(BodySlot::Back)
115 } else if template_id.contains("belt") {
116 Some(BodySlot::Waist)
117 } else if template_id.contains("cap") || template_id.contains("hat") || template_id.contains("helm") {
118 Some(BodySlot::Head)
119 } else if template_id.contains("shirt") || template_id.contains("robe") || template_id.contains("vest") {
120 Some(BodySlot::Body)
121 } else if template_id.contains("sleeves") || template_id.contains("gloves") || template_id.contains("gauntlets") {
122 Some(BodySlot::Arms)
123 } else if template_id.contains("pants") || template_id.contains("leggings") {
124 Some(BodySlot::Legs)
125 } else if template_id.contains("boots") || template_id.contains("shoes") {
126 Some(BodySlot::Feet)
127 } else {
128 None
129 }
130}
131
132#[derive(Debug, Clone)]
134pub struct InventoryRow {
135 pub depth: usize,
136 pub stack: flatland_protocol::ItemStack,
137 pub from: flatland_protocol::InventoryLocation,
139 pub from_parent_instance_id: Option<uuid::Uuid>,
141 pub is_equip_shell: bool,
143 pub is_chest_shell: bool,
145 pub section: InventorySection,
146}
147
148#[derive(Debug, Clone)]
150pub struct InventoryRowView {
151 pub depth: usize,
152 pub text: String,
153}
154
155#[derive(Debug, Clone)]
157pub enum InventoryBrowserLine {
158 Section(String),
159 SlotLabel(String),
160 Hint(String),
161 Blank,
162 Item {
163 selectable_index: usize,
164 selected: bool,
165 depth: usize,
166 text: String,
167 },
168}
169
170#[derive(Debug, Clone)]
173pub struct NearbyContainer {
174 pub view: flatland_protocol::PlacedContainerView,
175 pub distance_m: f32,
176 pub rows: Vec<InventoryRow>,
177}
178
179#[derive(Debug, Clone)]
181pub struct KeychainEntry {
182 pub stack: flatland_protocol::ItemStack,
183 pub stowed: bool,
184}
185
186#[derive(Debug, Clone)]
188pub struct MoveOption {
189 pub label: String,
190 pub kind: MoveOptionKind,
191}
192
193#[derive(Debug, Clone, PartialEq)]
194pub enum MoveOptionKind {
195 Move {
196 location: flatland_protocol::InventoryLocation,
197 parent_instance_id: Option<uuid::Uuid>,
198 },
199 Drop,
200 Cancel,
201}
202
203#[derive(Debug, Clone)]
205pub struct MovePicker {
206 pub item_instance_id: uuid::Uuid,
207 pub from: flatland_protocol::InventoryLocation,
208 pub item_label: String,
209 pub template_id: String,
210 pub stack_quantity: u32,
211 pub quantity: u32,
212 pub options: Vec<MoveOption>,
213}
214
215#[derive(Debug, Clone)]
217pub struct DestroyPicker {
218 pub item_instance_id: uuid::Uuid,
219 pub from: flatland_protocol::InventoryLocation,
220 pub item_label: String,
221 pub stack_quantity: u32,
222 pub quantity: u32,
223}
224
225fn push_inventory_rows(
226 rows: &mut Vec<InventoryRow>,
227 depth: usize,
228 stack: &flatland_protocol::ItemStack,
229 from: &flatland_protocol::InventoryLocation,
230 from_parent_instance_id: Option<uuid::Uuid>,
231 section: InventorySection,
232) {
233 rows.push(InventoryRow {
234 depth,
235 stack: stack.clone(),
236 from: from.clone(),
237 from_parent_instance_id,
238 is_equip_shell: false,
239 is_chest_shell: false,
240 section,
241 });
242 for child in &stack.contents {
243 push_inventory_rows(
244 rows,
245 depth + 1,
246 child,
247 from,
248 stack.item_instance_id,
249 section,
250 );
251 }
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
255pub enum ShopTab {
256 #[default]
257 Buy,
258 Sell,
259}
260
261#[derive(Debug, Clone, Default)]
262pub struct NpcChatState {
263 pub npc_id: String,
264 pub npc_label: String,
265 pub lines: Vec<String>,
266 pub input: String,
267 pub pending: bool,
268}
269
270#[derive(Debug, Clone)]
271pub struct GameState {
272 pub session_id: SessionId,
273 pub entity_id: EntityId,
274 pub character_id: Option<uuid::Uuid>,
276 pub tick: Tick,
277 pub chunk_rev: u64,
278 pub content_rev: u64,
279 pub entities: Vec<EntityState>,
280 pub player: Option<EntityState>,
281 pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
282 pub ground_drops: Vec<flatland_protocol::GroundDropView>,
283 pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
284 pub buildings: Vec<BuildingView>,
285 pub doors: Vec<DoorView>,
286 pub interior_map: Option<InteriorMapView>,
287 pub npcs: Vec<NpcView>,
288 pub blueprints: Vec<BlueprintView>,
289 pub world_width_m: f32,
290 pub world_height_m: f32,
291 pub terrain_zones: Vec<TerrainZoneView>,
292 pub z_platforms: Vec<ZPlatformView>,
293 pub z_transitions: Vec<ZTransitionView>,
294 pub world_clock: flatland_protocol::WorldClock,
295 pub inventory: std::collections::HashMap<String, u32>,
296 pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
297 pub logs: VecDeque<String>,
298 pub intents_sent: u64,
299 pub ticks_received: u64,
300 pub connected: bool,
301 pub disconnect_reason: Option<String>,
302 pub show_stats: bool,
303 pub show_craft_menu: bool,
304 pub craft_menu_index: usize,
305 pub craft_batch_quantity: u32,
307 pub show_shop_menu: bool,
308 pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
309 pub shop_tab: ShopTab,
310 pub shop_menu_index: usize,
311 pub shop_quantity: u32,
312 pub shop_trade_log: VecDeque<String>,
314 pub show_npc_verb_menu: bool,
315 pub npc_verb_target: Option<String>,
316 pub npc_verb_index: usize,
317 pub show_npc_chat: bool,
318 pub npc_chat: Option<NpcChatState>,
319 pub show_inventory_menu: bool,
320 pub inventory_menu_index: usize,
321 pub show_move_picker: bool,
322 pub move_picker_index: usize,
323 pub move_picker: Option<MovePicker>,
324 pub show_destroy_picker: bool,
325 pub destroy_confirm_pending: bool,
326 pub destroy_picker: Option<DestroyPicker>,
327 pub show_rename_prompt: bool,
329 pub rename_buffer: String,
330 pub combat_target: Option<EntityId>,
332 pub combat_target_label: Option<String>,
333 pub in_combat: bool,
334 pub auto_attack: bool,
335 pub combat_has_los: bool,
336 pub attack_cd_ticks: u64,
337 pub gcd_ticks: u64,
338 pub weapon_ability_id: String,
339 pub mainhand_template_id: Option<String>,
340 pub mainhand_label: Option<String>,
341 pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
344 pub carry_mass: f32,
345 pub carry_mass_max: f32,
346 pub encumbrance: flatland_protocol::EncumbranceState,
347 pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
349 pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
351 pub combat_target_detail: Option<CombatTargetHud>,
352 pub cast_progress: Option<CastProgressHud>,
353 pub ability_cooldowns: Vec<AbilityCooldownHud>,
354 pub blocking_active: bool,
355 pub max_target_slots: u8,
356 pub combat_slots: Vec<CombatSlotHud>,
357 pub rotation_presets: Vec<RotationPreset>,
358 pub show_loadout_menu: bool,
359 pub show_keychain_menu: bool,
360 pub keychain_menu_index: usize,
361 pub show_rotation_editor: bool,
362 pub loadout_menu_index: usize,
363 pub rotation_editor: RotationEditorState,
364 pub harvest_in_progress: bool,
366 pub harvest_started_at: Option<Instant>,
368 pub pending_craft_ack: Option<(u32, String, u32)>,
370 pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
371 pub interactables: Vec<flatland_protocol::InteractableView>,
372 pub show_quest_offer: bool,
373 pub pending_quest_offer: Option<flatland_protocol::QuestOffer>,
374 pub show_quest_menu: bool,
375 pub quest_menu_index: usize,
376 pub quest_withdraw_confirm: bool,
377}
378
379impl GameState {
380 pub fn push_log(&mut self, line: impl Into<String>) {
381 self.logs.push_back(line.into());
382 while self.logs.len() > MAX_LOG_LINES {
383 self.logs.pop_front();
384 }
385 }
386
387 pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
388 self.shop_trade_log.push_back(line.into());
389 while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
390 self.shop_trade_log.pop_front();
391 }
392 }
393
394 pub fn clear_shop_trade_log(&mut self) {
395 self.shop_trade_log.clear();
396 }
397
398 fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
399 if !self.show_shop_menu {
400 return;
401 }
402 let msg = notice.message.trim();
403 if msg.is_empty() {
404 return;
405 }
406 if notice.coins_delta != 0
407 || msg.starts_with("Bought ")
408 || msg.starts_with("Sold ")
409 || msg.contains("taught you how to craft")
410 || msg.starts_with("need ")
411 {
412 self.push_shop_trade_log(msg);
413 }
414 }
415
416 pub fn is_alive(&self) -> bool {
417 self.player
418 .as_ref()
419 .and_then(|p| p.vitals)
420 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
421 .unwrap_or(true)
422 }
423
424 pub fn npc_verb_options(&self) -> Vec<&'static str> {
426 let Some(ref id) = self.npc_verb_target else {
427 return vec![];
428 };
429 let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
430 return vec!["Talk"];
431 };
432 if npc.can_trade || Self::npc_role_can_trade(npc.role.as_str()) {
433 vec!["Talk", "Trade"]
434 } else {
435 vec!["Talk"]
436 }
437 }
438
439 fn npc_role_can_trade(role: &str) -> bool {
440 matches!(role, "broker" | "cook" | "farmer" | "merchant")
441 }
442
443 pub fn clear_harvest_state(&mut self) {
444 self.harvest_in_progress = false;
445 self.harvest_started_at = None;
446 }
447
448 fn harvest_state_stale(&self) -> bool {
449 match self.harvest_started_at {
450 Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
451 None => self.harvest_in_progress,
452 }
453 }
454
455 pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
456 self.player.as_ref().and_then(|p| p.vitals)
457 }
458
459 pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
460 let materials_ok = blueprint.inputs.iter().all(|input| {
461 self.inventory
462 .get(&input.template_id)
463 .copied()
464 .unwrap_or(0)
465 >= input.quantity
466 });
467 let tools_ok = blueprint.required_tools.iter().all(|tool| {
468 self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1
469 });
470 let station_ok = match blueprint.station.as_deref() {
471 None | Some("hand") => true,
472 Some(tag) => self.player_at_station_tag(tag),
473 };
474 materials_ok && tools_ok && station_ok
475 }
476
477 pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
478 if !self.can_craft_blueprint(blueprint) {
479 return 0;
480 }
481 let mut limit = u32::MAX;
482 for input in &blueprint.inputs {
483 if input.quantity == 0 {
484 continue;
485 }
486 let have = self
487 .inventory
488 .get(&input.template_id)
489 .copied()
490 .unwrap_or(0);
491 limit = limit.min(have / input.quantity);
492 }
493 for tool in &blueprint.required_tools {
494 if tool.consumed {
495 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
496 limit = limit.min(have);
497 }
498 }
499 let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
500 if CRAFT_STAMINA_COST > 0.0 {
501 limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
502 }
503 limit
504 }
505
506 pub fn clamp_craft_batch_quantity(&mut self) {
507 let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
508 self.craft_batch_quantity = 1;
509 return;
510 };
511 let max = self.max_craft_batches(bp).max(1);
512 self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
513 }
514
515 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
516 let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
517 return;
518 };
519 let max = self.max_craft_batches(&bp).max(1);
520 let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
521 self.craft_batch_quantity = next as u32;
522 }
523
524 pub fn craft_batch_set_max(&mut self) {
525 let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
526 return;
527 };
528 let max = self.max_craft_batches(&bp);
529 self.craft_batch_quantity = if max == 0 { 1 } else { max };
530 }
531
532 pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
533 let preserve_ui = self.show_shop_menu;
534 let tab = self.shop_tab;
535 let index = self.shop_menu_index;
536 let qty = self.shop_quantity;
537
538 self.show_shop_menu = true;
539 self.show_craft_menu = false;
540 self.show_inventory_menu = false;
541 self.show_stats = false;
542 self.shop_catalog = Some(catalog);
543
544 if preserve_ui {
545 self.shop_tab = tab;
546 self.shop_menu_index = index;
547 self.shop_quantity = qty;
548 } else {
549 self.shop_tab = ShopTab::Buy;
550 self.shop_menu_index = 0;
551 self.shop_quantity = 1;
552 self.clear_shop_trade_log();
553 }
554 self.clamp_shop_selection();
555 }
556
557 pub fn shop_list_len(&self) -> usize {
558 let Some(catalog) = &self.shop_catalog else {
559 return 0;
560 };
561 match self.shop_tab {
562 ShopTab::Buy => catalog.sells.len(),
563 ShopTab::Sell => catalog.buys.len(),
564 }
565 }
566
567 pub fn shop_menu_move(&mut self, delta: i32) {
568 let n = self.shop_list_len();
569 if n == 0 {
570 return;
571 }
572 let idx = self.shop_menu_index as i32;
573 let next = (idx + delta).rem_euclid(n as i32);
574 self.shop_menu_index = next as usize;
575 self.clamp_shop_quantity();
576 }
577
578 pub fn shop_quantity_adjust(&mut self, delta: i32) {
579 let max = self.shop_quantity_max();
580 let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
581 self.shop_quantity = next as u32;
582 }
583
584 pub(crate) fn clamp_shop_selection(&mut self) {
585 let n = self.shop_list_len();
586 if n == 0 {
587 self.shop_menu_index = 0;
588 } else {
589 self.shop_menu_index = self.shop_menu_index.min(n - 1);
590 }
591 self.clamp_shop_quantity();
592 }
593
594 fn shop_quantity_max(&self) -> u32 {
595 let Some(catalog) = &self.shop_catalog else {
596 return 1;
597 };
598 match self.shop_tab {
599 ShopTab::Buy => {
600 if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
601 if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
602 return 1;
603 }
604 }
605 99
606 }
607 ShopTab::Sell => catalog
608 .buys
609 .get(self.shop_menu_index)
610 .map(|l| l.quantity)
611 .unwrap_or(1)
612 .max(1),
613 }
614 }
615
616 pub fn shop_quantity_set_max(&mut self) {
617 self.shop_quantity = self.shop_quantity_max();
618 }
619
620 fn clamp_shop_quantity(&mut self) {
621 self.shop_quantity = self.shop_quantity.clamp(1, self.shop_quantity_max());
622 }
623
624 pub fn player_at_station_tag(&self, tag: &str) -> bool {
625 let Some(id) = self.effective_inside_building() else {
626 return false;
627 };
628 self.buildings
629 .iter()
630 .find(|b| b.id == id)
631 .is_some_and(|b| b.tags.iter().any(|t| t == tag))
632 }
633
634 pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
636 if self.can_craft_blueprint(blueprint) {
637 return None;
638 }
639 let mut missing = Vec::new();
640 for input in &blueprint.inputs {
641 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
642 if have < input.quantity {
643 missing.push(format!("{}×{} (have {have})", input.quantity, input.template_id));
644 }
645 }
646 for tool in &blueprint.required_tools {
647 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
648 if have < 1 {
649 missing.push(format!("tool: {}", tool.item));
650 }
651 }
652 if let Some(station) = blueprint.station.as_deref() {
653 if station != "hand" && !self.player_at_station_tag(station) {
654 missing.push(format!("station: {station} (enter building)"));
655 }
656 }
657 if missing.is_empty() {
658 None
659 } else {
660 Some(missing.join(", "))
661 }
662 }
663
664 pub fn player_entity(&self) -> Option<&EntityState> {
665 self.player
666 .as_ref()
667 .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
668 }
669
670 pub fn player_position(&self) -> (f32, f32) {
671 let (x, y, _) = self.player_position_with_z();
672 (x, y)
673 }
674
675 pub fn player_position_with_z(&self) -> (f32, f32, f32) {
676 if let Some(p) = self.player_entity() {
677 (
678 p.transform.position.x,
679 p.transform.position.y,
680 p.transform.position.z,
681 )
682 } else {
683 (0.0, 0.0, 0.0)
684 }
685 }
686
687 pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
688 let mut rows: Vec<(String, u32, String)> = self
689 .inventory
690 .iter()
691 .filter(|(_, q)| **q > 0)
692 .map(|(id, qty)| {
693 let label = self
694 .inventory_hints
695 .get(id)
696 .map(|h| h.display_name.clone())
697 .unwrap_or_else(|| id.clone());
698 (id.clone(), *qty, label)
699 })
700 .collect();
701 rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
702 rows
703 }
704
705 pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
706 self.inventory_hints
707 .get(template_id)
708 .map(|h| h.category.as_str())
709 .filter(|c| !c.is_empty())
710 }
711
712 pub fn item_base_mass(&self, template_id: &str) -> f32 {
713 self.inventory_hints
714 .get(template_id)
715 .and_then(|h| h.base_mass)
716 .unwrap_or(0.5)
717 }
718
719 pub fn item_base_volume(&self, template_id: &str) -> f32 {
720 self.inventory_hints
721 .get(template_id)
722 .and_then(|h| h.base_volume)
723 .unwrap_or(1.0)
724 }
725
726 pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
727 let unit = stack
728 .base_mass
729 .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
730 unit * stack.quantity as f32
731 }
732
733 fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
734 let unit = stack.base_volume.unwrap_or(1.0);
735 unit * stack.quantity as f32
736 + stack
737 .contents
738 .iter()
739 .map(Self::stack_tree_volume)
740 .sum::<f32>()
741 }
742
743 fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
744 contents.iter().map(Self::stack_tree_volume).sum()
745 }
746
747 fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
748 self.inventory_hints
749 .get(template_id)
750 .and_then(|h| h.capacity_volume)
751 .filter(|c| *c > 0.0)
752 }
753
754 fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
755 stack
756 .capacity_volume
757 .filter(|c| *c > 0.0)
758 .or_else(|| self.template_capacity_volume(&stack.template_id))
759 }
760
761 pub fn container_volume_label(&self, row: &InventoryRow) -> String {
763 let Some((used, cap)) = self.container_volume_stats(row) else {
764 return String::new();
765 };
766 let free = (cap - used).max(0.0);
767 format!(" vol {used:.0}/{cap:.0} ({free:.0} free)")
768 }
769
770 fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
771 if row.is_chest_shell {
772 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
773 return None;
774 };
775 let chest = self
776 .placed_containers
777 .iter()
778 .find(|c| c.id == *container_id)?;
779 let cap = self
780 .stack_capacity_volume(&row.stack)
781 .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
782 let used = if chest.accessible {
783 Self::contents_used_volume(&chest.contents)
784 } else {
785 0.0
786 };
787 return Some((used, cap));
788 }
789
790 let cap = self.stack_capacity_volume(&row.stack)?;
791 let used = Self::contents_used_volume(&row.stack.contents);
792 Some((used, cap))
793 }
794
795 pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
796 if row.is_chest_shell {
797 return true;
798 }
799 if row.is_equip_shell {
800 return self.inventory_item_category(&row.stack.template_id) == Some("container");
801 }
802 self.inventory_item_category(&row.stack.template_id) == Some("container")
803 || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
804 }
805
806 fn container_stack_for(
807 &self,
808 location: &flatland_protocol::InventoryLocation,
809 parent_instance_id: Option<uuid::Uuid>,
810 ) -> Option<flatland_protocol::ItemStack> {
811 match location {
812 flatland_protocol::InventoryLocation::Root => {
813 let pid = parent_instance_id?;
814 self.find_stack_by_instance(&self.inventory_stacks, pid)
815 }
816 flatland_protocol::InventoryLocation::Worn { slot } => {
817 let worn = self.worn.get(slot)?;
818 if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
819 Some(worn.clone())
820 } else {
821 self.find_stack_by_instance(&worn.contents, parent_instance_id?)
822 }
823 }
824 flatland_protocol::InventoryLocation::Placed { container_id } => {
825 let chest = self.placed_containers.iter().find(|c| c.id == *container_id)?;
826 if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
827 Some(flatland_protocol::ItemStack {
828 template_id: chest.template_id.clone(),
829 quantity: 1,
830 item_instance_id: chest.item_instance_id,
831 props: Default::default(),
832 contents: chest.contents.clone(),
833 display_name: Some(chest.display_name.clone()),
834 category: Some("container".into()),
835 base_mass: None,
836 base_volume: None,
837 capacity_volume: self
838 .inventory_hints
839 .get(&chest.template_id)
840 .and_then(|h| h.capacity_volume),
841 stackable: None,
842 })
843 } else {
844 self.find_stack_by_instance(&chest.contents, parent_instance_id?)
845 }
846 }
847 flatland_protocol::InventoryLocation::Keychain => None,
848 }
849 }
850
851 fn find_stack_by_instance(
852 &self,
853 stacks: &[flatland_protocol::ItemStack],
854 instance_id: uuid::Uuid,
855 ) -> Option<flatland_protocol::ItemStack> {
856 for stack in stacks {
857 if stack.item_instance_id == Some(instance_id) {
858 return Some(stack.clone());
859 }
860 if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
861 return Some(found);
862 }
863 }
864 None
865 }
866
867 pub fn max_movable_to(
869 &self,
870 template_id: &str,
871 stack_qty: u32,
872 from: &flatland_protocol::InventoryLocation,
873 to: &flatland_protocol::InventoryLocation,
874 parent_instance_id: Option<uuid::Uuid>,
875 ) -> u32 {
876 let unit_vol = self.item_base_volume(template_id);
877 let unit_mass = self.item_base_mass(template_id);
878 let mut limit = stack_qty;
879
880 if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
881 let cap = parent
882 .capacity_volume
883 .or_else(|| {
884 self.inventory_hints
885 .get(&parent.template_id)
886 .and_then(|h| h.capacity_volume)
887 })
888 .unwrap_or(0.0);
889 if cap > 0.0 && unit_vol > 0.0 {
890 let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
891 limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
892 }
893 }
894
895 let to_person = matches!(
896 to,
897 flatland_protocol::InventoryLocation::Root | flatland_protocol::InventoryLocation::Worn { .. }
898 );
899 let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
900 if to_person && from_placed && unit_mass > 0.0 {
901 let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
902 if self.encumbrance == flatland_protocol::EncumbranceState::Over {
903 limit = 0;
904 } else {
905 limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
906 }
907 }
908
909 limit.max(0).min(stack_qty)
910 }
911
912 pub fn move_picker_max_at_selection(&self) -> u32 {
913 let Some(picker) = &self.move_picker else {
914 return 1;
915 };
916 let Some(opt) = picker.options.get(self.move_picker_index) else {
917 return picker.stack_quantity;
918 };
919 match &opt.kind {
920 MoveOptionKind::Cancel | MoveOptionKind::Drop => picker.stack_quantity,
921 MoveOptionKind::Move {
922 location,
923 parent_instance_id,
924 } => self.max_movable_to(
925 &picker.template_id,
926 picker.stack_quantity,
927 &picker.from,
928 location,
929 *parent_instance_id,
930 ),
931 }
932 }
933
934 pub fn clamp_move_picker_quantity(&mut self) {
935 let max = self.move_picker_max_at_selection();
936 if let Some(picker) = &mut self.move_picker {
937 if max == 0 {
938 picker.quantity = 1;
939 } else {
940 picker.quantity = picker.quantity.clamp(1, max);
941 }
942 }
943 }
944
945 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
946 let max = self.move_picker_max_at_selection().max(1);
947 if let Some(picker) = &mut self.move_picker {
948 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
949 picker.quantity = next as u32;
950 }
951 }
952
953 pub fn move_picker_set_quantity_max(&mut self) {
954 let max = self.move_picker_max_at_selection();
955 if let Some(picker) = &mut self.move_picker {
956 picker.quantity = if max == 0 { 1 } else { max.min(picker.stack_quantity) };
957 }
958 }
959
960 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
961 if let Some(picker) = &mut self.destroy_picker {
962 let max = picker.stack_quantity.max(1);
963 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
964 picker.quantity = next as u32;
965 }
966 }
967
968 pub fn destroy_picker_set_quantity_max(&mut self) {
969 if let Some(picker) = &mut self.destroy_picker {
970 picker.quantity = picker.stack_quantity.max(1);
971 }
972 }
973
974 pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
975 let have = self.inventory.get(template_id).copied().unwrap_or(0);
976 (have, have >= need)
977 }
978
979 pub fn currency_display(&self) -> String {
980 crate::currency::currency_line(&self.inventory)
981 }
982
983 pub fn in_shallow_water(&self) -> bool {
985 let (px, py) = self.player_position();
986 self.terrain_at(px, py)
987 .is_some_and(|k| k == TerrainKindView::ShallowWater)
988 }
989
990 pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
991 self.terrain_zone_at(x, y).map(|z| z.kind)
992 }
993
994 pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
996 self.terrain_zones.iter().find(|z| {
997 x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1
998 })
999 }
1000
1001 pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
1003 self.terrain_zone_at(x, y)
1004 .map(|z| z.elevation)
1005 .unwrap_or(0.0)
1006 }
1007
1008 pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
1010 const TOL: f32 = 0.35;
1011 let mut levels = vec![self.elevation_at(x, y)];
1012 for p in &self.z_platforms {
1013 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
1014 levels.push(p.z);
1015 }
1016 }
1017 levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1018 levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
1019 levels
1020 }
1021
1022 pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
1023 const TOL: f32 = 0.35;
1024 self.walkable_levels_at(x, y)
1025 .iter()
1026 .any(|&l| (l - z).abs() <= TOL)
1027 }
1028
1029 pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
1030 let mut top = self.elevation_at(x, y);
1031 for p in &self.z_platforms {
1032 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
1033 top = top.max(p.z);
1034 }
1035 }
1036 top
1037 }
1038
1039 pub fn effective_inside_building(&self) -> Option<String> {
1041 self.player_entity()
1042 .and_then(|p| p.inside_building.clone())
1043 }
1044
1045 pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
1046 self.inventory_stacks = stacks.to_vec();
1047 self.inventory.clear();
1048 self.inventory_hints.clear();
1049 fn walk(
1050 stacks: &[flatland_protocol::ItemStack],
1051 inventory: &mut std::collections::HashMap<String, u32>,
1052 hints: &mut std::collections::HashMap<String, InventoryHint>,
1053 ) {
1054 for stack in stacks {
1055 *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
1056 if stack.display_name.is_some()
1057 || stack.category.is_some()
1058 || stack.base_mass.is_some()
1059 || stack.base_volume.is_some()
1060 {
1061 hints.insert(
1062 stack.template_id.clone(),
1063 InventoryHint {
1064 display_name: stack
1065 .display_name
1066 .clone()
1067 .unwrap_or_else(|| stack.template_id.clone()),
1068 category: stack.category.clone().unwrap_or_default(),
1069 base_mass: stack.base_mass,
1070 base_volume: stack.base_volume,
1071 capacity_volume: stack.capacity_volume,
1072 stackable: stack.stackable.unwrap_or(true),
1073 },
1074 );
1075 }
1076 walk(&stack.contents, inventory, hints);
1077 }
1078 }
1079 walk(stacks, &mut self.inventory, &mut self.inventory_hints);
1080 for item in self.worn.values() {
1082 walk(std::slice::from_ref(item), &mut self.inventory, &mut self.inventory_hints);
1083 }
1084 }
1085
1086 pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1089 let subtract_items = notice.message.starts_with("Sold ")
1090 || notice.message.starts_with("Consumed ");
1091 for stack in ¬ice.inventory_delta {
1092 if stack.quantity == 0 {
1093 continue;
1094 }
1095 if subtract_items {
1096 crate::currency::drain_template_stacks(
1097 &mut self.inventory_stacks,
1098 &stack.template_id,
1099 stack.quantity,
1100 );
1101 continue;
1102 }
1103 let stackable = self
1104 .inventory_hints
1105 .get(&stack.template_id)
1106 .map(|h| h.stackable)
1107 .or(stack.stackable)
1108 .unwrap_or(true);
1109 if stackable {
1110 if let Some(existing) = self
1111 .inventory_stacks
1112 .iter_mut()
1113 .find(|s| s.template_id == stack.template_id)
1114 {
1115 existing.quantity = existing.quantity.saturating_add(stack.quantity);
1116 if stack.display_name.is_some() {
1117 existing.display_name = stack.display_name.clone();
1118 }
1119 if stack.category.is_some() {
1120 existing.category = stack.category.clone();
1121 }
1122 continue;
1123 }
1124 }
1125 self.inventory_stacks.push(stack.clone());
1126 }
1127 if notice.coins_delta != 0 {
1128 crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
1129 }
1130 if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
1131 let stacks = self.inventory_stacks.clone();
1132 self.sync_inventory_from_stacks(&stacks);
1133 }
1134 self.record_shop_trade_notice(notice);
1135 }
1136
1137 pub fn worn_rows(&self) -> Vec<InventoryRow> {
1142 let mut rows = Vec::new();
1143 for (slot, item) in &self.worn {
1144 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
1145 rows.push(InventoryRow {
1146 depth: 0,
1147 stack: item.clone(),
1148 from: from.clone(),
1149 from_parent_instance_id: None,
1150 is_equip_shell: true,
1151 is_chest_shell: false,
1152 section: InventorySection::Worn,
1153 });
1154 for child in &item.contents {
1155 push_inventory_rows(
1156 &mut rows,
1157 1,
1158 child,
1159 &from,
1160 item.item_instance_id,
1161 InventorySection::Worn,
1162 );
1163 }
1164 }
1165 rows
1166 }
1167
1168 pub fn person_rows(&self) -> Vec<InventoryRow> {
1170 let mut rows = Vec::new();
1171 for stack in &self.inventory_stacks {
1172 push_inventory_rows(
1173 &mut rows,
1174 0,
1175 stack,
1176 &flatland_protocol::InventoryLocation::Root,
1177 None,
1178 InventorySection::Person,
1179 );
1180 }
1181 rows
1182 }
1183
1184 pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
1186 let mut rows = self.worn_rows();
1187 rows.extend(self.person_rows());
1188 rows.into_iter().map(|r| (r.depth, r.stack)).collect()
1189 }
1190
1191 pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
1195 let (px, py) = self.player_position();
1196 let mut list: Vec<NearbyContainer> = self
1197 .placed_containers
1198 .iter()
1199 .filter_map(|c| {
1200 let distance_m = (c.x - px).hypot(c.y - py);
1201 if distance_m > CONTAINER_RANGE_M {
1202 return None;
1203 }
1204 let mut rows = Vec::new();
1205 let from = flatland_protocol::InventoryLocation::Placed {
1206 container_id: c.id.clone(),
1207 };
1208 rows.push(InventoryRow {
1209 depth: 0,
1210 stack: flatland_protocol::ItemStack {
1211 template_id: c.template_id.clone(),
1212 quantity: 1,
1213 item_instance_id: c.item_instance_id,
1214 props: Default::default(),
1215 contents: Vec::new(),
1216 display_name: Some(c.display_name.clone()),
1217 category: Some("container".into()),
1218 base_mass: None,
1219 base_volume: None,
1220 capacity_volume: c.capacity_volume,
1221 stackable: None,
1222 },
1223 from: from.clone(),
1224 from_parent_instance_id: None,
1225 is_equip_shell: false,
1226 is_chest_shell: true,
1227 section: InventorySection::Nearby,
1228 });
1229 if c.accessible {
1230 for child in &c.contents {
1231 push_inventory_rows(
1232 &mut rows,
1233 1,
1234 child,
1235 &from,
1236 c.item_instance_id,
1237 InventorySection::Nearby,
1238 );
1239 }
1240 }
1241 Some(NearbyContainer {
1242 view: c.clone(),
1243 distance_m,
1244 rows,
1245 })
1246 })
1247 .collect();
1248 list.sort_by(|a, b| {
1249 a.distance_m
1250 .partial_cmp(&b.distance_m)
1251 .unwrap_or(std::cmp::Ordering::Equal)
1252 });
1253 list
1254 }
1255
1256 pub fn nearest_placed_container(
1258 &self,
1259 max_dist: f32,
1260 ) -> Option<flatland_protocol::PlacedContainerView> {
1261 let (px, py) = self.player_position();
1262 self.placed_containers
1263 .iter()
1264 .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
1265 .min_by(|a, b| {
1266 let da = (a.x - px).hypot(a.y - py);
1267 let db = (b.x - px).hypot(b.y - py);
1268 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
1269 })
1270 .cloned()
1271 }
1272
1273 pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
1279 let mut rows = self.worn_rows();
1280 rows.extend(self.person_rows());
1281 for nc in self.nearby_containers() {
1282 rows.extend(nc.rows);
1283 }
1284 rows
1285 }
1286
1287 pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
1288 self.inventory_selectable_rows()
1289 .into_iter()
1290 .nth(self.inventory_menu_index)
1291 }
1292
1293 pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
1295 let cat = self
1296 .inventory_item_category(&row.stack.template_id)
1297 .unwrap_or("");
1298 let label = if cat == "key" {
1299 self.key_inventory_label(&row.stack)
1300 } else {
1301 row.stack
1302 .display_name
1303 .clone()
1304 .unwrap_or_else(|| row.stack.template_id.clone())
1305 };
1306 let hint: String = if row.is_equip_shell {
1307 " [worn — Enter to unequip]".into()
1308 } else if row.is_chest_shell {
1309 let locked = match &row.from {
1310 flatland_protocol::InventoryLocation::Placed { container_id } => self
1311 .placed_containers
1312 .iter()
1313 .find(|c| c.id == *container_id)
1314 .map(|c| c.locked)
1315 .unwrap_or(false),
1316 _ => false,
1317 };
1318 if locked {
1319 " [locked — Enter/l to unlock]".into()
1320 } else {
1321 " [Enter/l to lock]".into()
1322 }
1323 } else if cat == "key" {
1324 self.key_inventory_hint(&row.stack)
1325 } else {
1326 match cat {
1327 "weapon" => " [weapon]".into(),
1328 "container" => " [bag/chest/belt]".into(),
1329 "armor" => " [armor]".into(),
1330 _ => String::new(),
1331 }
1332 };
1333 let qty = if row.stack.quantity > 1 {
1334 format!(" ×{}", row.stack.quantity)
1335 } else {
1336 String::new()
1337 };
1338 let mass = self.stack_mass(&row.stack);
1339 let mass_str = if mass >= 0.05 {
1340 format!(" {:.1} kg", mass)
1341 } else {
1342 String::new()
1343 };
1344 let vol_str = self.container_volume_label(row);
1345 InventoryRowView {
1346 depth: row.depth,
1347 text: format!("{label}{hint}{qty}{mass_str}{vol_str}"),
1348 }
1349 }
1350
1351 pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
1354 let mut lines = Vec::new();
1355 let target = self.inventory_menu_index;
1356 let highlight = !self.show_move_picker;
1357 let mut global_idx = 0usize;
1358
1359 lines.push(InventoryBrowserLine::Section("— Worn —".into()));
1360 let worn = self.worn_rows();
1361 if worn.is_empty() {
1362 lines.push(InventoryBrowserLine::Hint(
1363 " (nothing equipped — wear a backpack/belt from \"On you\" below)".into(),
1364 ));
1365 } else {
1366 for row in &worn {
1367 if row.is_equip_shell {
1368 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
1369 lines.push(InventoryBrowserLine::SlotLabel(format!(
1370 " {}:",
1371 body_slot_label(slot)
1372 )));
1373 }
1374 }
1375 let view = self.format_inventory_row(row);
1376 lines.push(InventoryBrowserLine::Item {
1377 selectable_index: global_idx,
1378 selected: highlight && global_idx == target,
1379 depth: view.depth,
1380 text: view.text,
1381 });
1382 global_idx += 1;
1383 }
1384 }
1385
1386 lines.push(InventoryBrowserLine::Blank);
1387 lines.push(InventoryBrowserLine::Section(
1388 "— On you (loose, not worn) —".into(),
1389 ));
1390 let person = self.person_rows();
1391 if person.is_empty() {
1392 lines.push(InventoryBrowserLine::Hint(" (empty)".into()));
1393 } else {
1394 for row in &person {
1395 let view = self.format_inventory_row(row);
1396 lines.push(InventoryBrowserLine::Item {
1397 selectable_index: global_idx,
1398 selected: highlight && global_idx == target,
1399 depth: view.depth,
1400 text: view.text,
1401 });
1402 global_idx += 1;
1403 }
1404 }
1405
1406 let nearby = self.nearby_containers();
1407 if nearby.is_empty() {
1408 lines.push(InventoryBrowserLine::Blank);
1409 lines.push(InventoryBrowserLine::Section("— Nearby chests —".into()));
1410 lines.push(InventoryBrowserLine::Hint(
1411 " (none within reach — walk up to a chest)".into(),
1412 ));
1413 } else {
1414 for nc in &nearby {
1415 lines.push(InventoryBrowserLine::Blank);
1416 let lock_note = if nc.view.locked && nc.view.accessible {
1417 " unlocked with your key"
1418 } else if nc.view.locked {
1419 " locked"
1420 } else {
1421 ""
1422 };
1423 lines.push(InventoryBrowserLine::Section(format!(
1424 "— {} ({:.0}m away){lock_note} —",
1425 nc.view.display_name, nc.distance_m
1426 )));
1427 if !nc.view.accessible {
1428 lines.push(InventoryBrowserLine::Hint(
1429 " locked — need the matching key (l to try)".into(),
1430 ));
1431 } else if nc.rows.is_empty() {
1432 lines.push(InventoryBrowserLine::Hint(
1433 " (empty — select chest row above, m to move items in)".into(),
1434 ));
1435 } else {
1436 for row in &nc.rows {
1437 let view = self.format_inventory_row(row);
1438 lines.push(InventoryBrowserLine::Item {
1439 selectable_index: global_idx,
1440 selected: highlight && global_idx == target,
1441 depth: view.depth,
1442 text: view.text,
1443 });
1444 global_idx += 1;
1445 }
1446 }
1447 }
1448 }
1449 lines
1450 }
1451
1452 pub fn move_destinations_for(
1454 &self,
1455 from: &flatland_protocol::InventoryLocation,
1456 from_parent_instance_id: Option<uuid::Uuid>,
1457 moving_instance_id: Option<uuid::Uuid>,
1458 moving_template_id: &str,
1459 ) -> Vec<MoveOption> {
1460 let mut opts = Vec::new();
1461 if *from != flatland_protocol::InventoryLocation::Root {
1462 opts.push(MoveOption {
1463 label: "On your person (loose)".into(),
1464 kind: MoveOptionKind::Move {
1465 location: flatland_protocol::InventoryLocation::Root,
1466 parent_instance_id: None,
1467 },
1468 });
1469 }
1470 for (slot, item) in &self.worn {
1471 if item.category.as_deref() != Some("container") {
1472 continue;
1473 }
1474 let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
1475 let shell_name = item
1476 .display_name
1477 .clone()
1478 .unwrap_or_else(|| item.template_id.clone());
1479
1480 if *slot != BodySlot::Waist
1482 && item.item_instance_id != moving_instance_id
1483 && Self::is_volume_container_stack(item)
1484 {
1485 Self::push_move_destination(
1486 &mut opts,
1487 format!("{shell_name} (worn {})", body_slot_label(*slot)),
1488 location.clone(),
1489 item.item_instance_id,
1490 from,
1491 from_parent_instance_id,
1492 );
1493 }
1494
1495 if *slot == BodySlot::Waist
1497 && Self::attaches_to_belt_loop(moving_template_id)
1498 && item.item_instance_id != moving_instance_id
1499 {
1500 Self::push_move_destination(
1501 &mut opts,
1502 format!("{shell_name} (belt loop)"),
1503 location.clone(),
1504 item.item_instance_id,
1505 from,
1506 from_parent_instance_id,
1507 );
1508 }
1509
1510 let context = if *slot == BodySlot::Waist {
1511 format!("on {shell_name}")
1512 } else {
1513 format!("in {shell_name}")
1514 };
1515 Self::append_nested_container_destinations(
1516 &mut opts,
1517 location,
1518 item,
1519 &context,
1520 from,
1521 from_parent_instance_id,
1522 moving_instance_id,
1523 );
1524 }
1525 for nc in self.nearby_containers() {
1526 if !nc.view.accessible {
1527 continue;
1528 }
1529 let location = flatland_protocol::InventoryLocation::Placed {
1530 container_id: nc.view.id.clone(),
1531 };
1532 Self::push_move_destination(
1533 &mut opts,
1534 format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
1535 location,
1536 nc.view.item_instance_id,
1537 from,
1538 from_parent_instance_id,
1539 );
1540 }
1541 let allow_drop = moving_instance_id
1542 .and_then(|id| self.stack_for_instance(id))
1543 .map(|stack| !self.key_drop_blocked(&stack))
1544 .unwrap_or(moving_template_id != KEY_TEMPLATE);
1545 if allow_drop {
1546 opts.push(MoveOption {
1547 label: "Drop on the ground".into(),
1548 kind: MoveOptionKind::Drop,
1549 });
1550 }
1551 opts.push(MoveOption {
1552 label: "Cancel".into(),
1553 kind: MoveOptionKind::Cancel,
1554 });
1555 opts
1556 }
1557
1558 fn is_same_container_dest(
1559 dest_location: &flatland_protocol::InventoryLocation,
1560 dest_parent: Option<uuid::Uuid>,
1561 from: &flatland_protocol::InventoryLocation,
1562 from_parent: Option<uuid::Uuid>,
1563 ) -> bool {
1564 dest_location == from && dest_parent == from_parent
1565 }
1566
1567 fn push_move_destination(
1568 opts: &mut Vec<MoveOption>,
1569 label: String,
1570 location: flatland_protocol::InventoryLocation,
1571 parent_instance_id: Option<uuid::Uuid>,
1572 from: &flatland_protocol::InventoryLocation,
1573 from_parent_instance_id: Option<uuid::Uuid>,
1574 ) {
1575 if Self::is_same_container_dest(
1576 &location,
1577 parent_instance_id,
1578 from,
1579 from_parent_instance_id,
1580 ) {
1581 return;
1582 }
1583 opts.push(MoveOption {
1584 label,
1585 kind: MoveOptionKind::Move {
1586 location,
1587 parent_instance_id,
1588 },
1589 });
1590 }
1591
1592 fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
1593 stack.capacity_volume.is_some_and(|c| c > 0.0)
1594 }
1595
1596 fn attaches_to_belt_loop(template_id: &str) -> bool {
1597 matches!(template_id, "leather_pouch" | "dimensional_pouch")
1598 }
1599
1600 fn append_nested_container_destinations(
1601 opts: &mut Vec<MoveOption>,
1602 location: flatland_protocol::InventoryLocation,
1603 container: &flatland_protocol::ItemStack,
1604 context: &str,
1605 from: &flatland_protocol::InventoryLocation,
1606 from_parent_instance_id: Option<uuid::Uuid>,
1607 moving_instance_id: Option<uuid::Uuid>,
1608 ) {
1609 for child in &container.contents {
1610 if Self::is_volume_container_stack(child)
1611 && child.item_instance_id != moving_instance_id
1612 {
1613 let name = child
1614 .display_name
1615 .clone()
1616 .unwrap_or_else(|| child.template_id.clone());
1617 Self::push_move_destination(
1618 opts,
1619 format!("{name} ({context})"),
1620 location.clone(),
1621 child.item_instance_id,
1622 from,
1623 from_parent_instance_id,
1624 );
1625 }
1626 let nested_context = format!(
1627 "in {}",
1628 child
1629 .display_name
1630 .as_deref()
1631 .unwrap_or(&child.template_id)
1632 );
1633 Self::append_nested_container_destinations(
1634 opts,
1635 location.clone(),
1636 child,
1637 &nested_context,
1638 from,
1639 from_parent_instance_id,
1640 moving_instance_id,
1641 );
1642 }
1643 }
1644
1645 fn clamp_inventory_indices(&mut self) {
1646 let n = self.inventory_selectable_rows().len();
1647 self.inventory_menu_index = if n == 0 {
1648 0
1649 } else {
1650 self.inventory_menu_index.min(n - 1)
1651 };
1652 if let Some(picker) = &self.move_picker {
1653 let pn = picker.options.len();
1654 self.move_picker_index = if pn == 0 {
1655 0
1656 } else {
1657 self.move_picker_index.min(pn - 1)
1658 };
1659 }
1660 }
1661
1662 fn sync_interior_map_context(&mut self) {
1664 if self.effective_inside_building().is_none() {
1665 self.interior_map = None;
1666 }
1667 }
1668
1669 fn apply_snapshot_fields(&mut self, snapshot: &flatland_protocol::Snapshot, entity_id: EntityId) {
1670 self.tick = snapshot.tick;
1671 self.chunk_rev = snapshot.chunk_rev;
1672 self.content_rev = snapshot.content_rev;
1673 self.resource_nodes = snapshot.resource_nodes.clone();
1674 self.ground_drops = snapshot.ground_drops.clone();
1675 self.placed_containers = snapshot.placed_containers.clone();
1676 self.world_width_m = snapshot.world_width_m;
1677 self.world_height_m = snapshot.world_height_m;
1678 self.world_clock = snapshot.world_clock;
1679 self.terrain_zones = snapshot.terrain_zones.clone();
1680 self.z_platforms = snapshot.z_platforms.clone();
1681 self.z_transitions = snapshot.z_transitions.clone();
1682 self.buildings = snapshot.buildings.clone();
1683 self.doors = snapshot.doors.clone();
1684 self.interior_map = snapshot.interior_map.clone();
1685 self.npcs = snapshot.npcs.clone();
1686 self.blueprints = snapshot.blueprints.clone();
1687 self.sync_inventory_from_stacks(&snapshot.inventory);
1688 self.player = snapshot
1689 .entities
1690 .iter()
1691 .find(|e| e.id == entity_id)
1692 .cloned();
1693 self.entities = snapshot.entities.clone();
1694 self.quest_log = snapshot.quest_log.clone();
1695 self.interactables = snapshot.interactables.clone();
1696 self.sync_interior_map_context();
1697 }
1698
1699 fn refresh_inventory_ui(&mut self) {
1703 if let Some(picker) = &self.move_picker {
1704 let instance_id = picker.item_instance_id;
1705 let still_exists = self
1706 .inventory_selectable_rows()
1707 .iter()
1708 .any(|r| r.stack.item_instance_id == Some(instance_id));
1709 if !still_exists {
1710 self.move_picker = None;
1711 self.show_move_picker = false;
1712 }
1713 }
1714 if let Some(picker) = &self.destroy_picker {
1715 let instance_id = picker.item_instance_id;
1716 let still_exists = self
1717 .inventory_selectable_rows()
1718 .iter()
1719 .any(|r| r.stack.item_instance_id == Some(instance_id));
1720 if !still_exists {
1721 self.destroy_picker = None;
1722 self.show_destroy_picker = false;
1723 self.destroy_confirm_pending = false;
1724 }
1725 }
1726 self.clamp_inventory_indices();
1727 }
1728
1729 fn apply_combat_hud(&mut self, combat: &CombatHud) {
1730 self.in_combat = combat.in_combat;
1731 self.auto_attack = combat.auto_attack;
1732 self.combat_has_los = combat.has_los;
1733 self.attack_cd_ticks = combat.attack_cd_ticks;
1734 self.gcd_ticks = combat.gcd_ticks;
1735 self.weapon_ability_id = combat.ability_id.clone();
1736 self.mainhand_template_id = combat.mainhand_template_id.clone();
1737 self.mainhand_label = combat.mainhand_label.clone();
1738 self.worn = combat.worn.iter().cloned().collect();
1739 self.carry_mass = combat.carry_mass;
1740 self.carry_mass_max = combat.carry_mass_max;
1741 self.encumbrance = combat.encumbrance;
1742 self.cast_progress = combat.cast.clone();
1743 self.ability_cooldowns = combat.ability_cooldowns.clone();
1744 self.blocking_active = combat.blocking_active;
1745 self.max_target_slots = combat.max_target_slots.max(1);
1746 self.combat_slots = combat.slots.clone();
1747 self.rotation_presets = combat.rotation_presets.clone();
1748 self.keychain_stacks = combat.keychain.clone();
1749 self.combat_target_detail = combat.target.clone();
1750 self.combat_target = combat.target_entity_id;
1751 if let Some(label) = &combat.target_label {
1752 self.combat_target_label = Some(label.clone());
1753 } else if let Some(id) = combat.target_entity_id {
1754 self.combat_target_label = self
1755 .entities
1756 .iter()
1757 .find(|e| e.id == id)
1758 .map(|e| e.label.clone())
1759 .or_else(|| self.combat_target_label.clone());
1760 }
1761 self.refresh_inventory_ui();
1762 }
1763
1764 pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
1766 self.combat_slots
1767 .iter()
1768 .find(|s| s.slot_index == slot)
1769 .and_then(|s| s.target_entity_id)
1770 .or_else(|| {
1771 if slot == 1 {
1772 self.combat_target
1773 } else {
1774 None
1775 }
1776 })
1777 }
1778
1779 pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
1781 self.combat_candidates()
1782 }
1783
1784 pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
1786 let (px, py) = self.player_position();
1787 let dist = |id: EntityId| {
1788 self.entities
1789 .iter()
1790 .find(|e| e.id == id)
1791 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
1792 .unwrap_or(f32::MAX)
1793 };
1794
1795 let mut allies = Vec::new();
1796 if let Some(me) = self.player.as_ref() {
1798 let alive = me
1799 .vitals
1800 .as_ref()
1801 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1802 .unwrap_or(true);
1803 if alive {
1804 allies.push((self.entity_id, "Yourself".into()));
1805 }
1806 }
1807 for entity in &self.entities {
1808 if entity.id == self.entity_id {
1809 continue;
1810 }
1811 if entity.vitals.is_some() {
1812 let alive = entity
1813 .vitals
1814 .as_ref()
1815 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1816 .unwrap_or(true);
1817 if alive {
1818 allies.push((entity.id, entity.label.clone()));
1819 }
1820 }
1821 }
1822 allies.sort_by(|(a, _), (b, _)| {
1823 if *a == self.entity_id {
1824 return std::cmp::Ordering::Less;
1825 }
1826 if *b == self.entity_id {
1827 return std::cmp::Ordering::Greater;
1828 }
1829 dist(*a)
1830 .partial_cmp(&dist(*b))
1831 .unwrap_or(std::cmp::Ordering::Equal)
1832 });
1833
1834 let mut monsters = self.combat_candidates();
1835 monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
1836 allies.into_iter().chain(monsters).collect()
1837 }
1838
1839 fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
1840 match slot_index {
1841 2 => self.t2_candidates(),
1842 _ => self.t1_candidates(),
1843 }
1844 }
1845
1846 pub(crate) fn restore_from_welcome(
1848 &mut self,
1849 session_id: SessionId,
1850 entity_id: EntityId,
1851 snapshot: &flatland_protocol::Snapshot,
1852 ) {
1853 self.clear_harvest_state();
1854 self.disconnect_reason = None;
1855 self.show_stats = false;
1856 self.show_craft_menu = false;
1857 self.show_shop_menu = false;
1858 self.shop_catalog = None;
1859 self.show_inventory_menu = false;
1860 self.session_id = session_id;
1861 self.entity_id = entity_id;
1862 self.connected = true;
1863 self.apply_snapshot_fields(snapshot, entity_id);
1864 if let Some(combat) = &snapshot.combat {
1865 self.apply_combat_hud(combat);
1866 let stacks = self.inventory_stacks.clone();
1867 self.sync_inventory_from_stacks(&stacks);
1868 }
1869 }
1870
1871 fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
1872 self.tick = delta.tick;
1873 self.world_clock = delta.world_clock;
1874
1875 if delta.entities.is_empty() {
1877 if let Some(combat) = &delta.combat {
1878 self.apply_combat_hud(combat);
1879 let stacks = self.inventory_stacks.clone();
1880 self.sync_inventory_from_stacks(&stacks);
1881 }
1882 return;
1883 }
1884 if !delta.buildings.is_empty() {
1885 self.buildings = delta.buildings.clone();
1886 }
1887 if !delta.blueprints.is_empty() {
1888 self.blueprints = delta.blueprints.clone();
1889 }
1890 self.sync_inventory_from_stacks(&delta.inventory);
1891
1892 if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
1893 self.player = Some(updated.clone());
1894 }
1895 self.entities = delta.entities.clone();
1896 if self.player.is_none() {
1897 self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
1898 }
1899
1900 self.sync_interior_map_context();
1901
1902 if !delta.resource_nodes.is_empty() {
1905 self.resource_nodes = delta.resource_nodes.clone();
1906 }
1907 if self.player.as_ref().is_none_or(|p| p.inside_building.is_none()) {
1908 self.ground_drops = delta.ground_drops.clone();
1909 self.placed_containers = delta.placed_containers.clone();
1910 }
1911 if !delta.doors.is_empty() {
1912 self.doors = delta.doors.clone();
1913 }
1914 if self.effective_inside_building().is_some() {
1915 if let Some(map) = &delta.interior_map {
1916 self.interior_map = Some(map.clone());
1917 }
1918 } else {
1919 self.interior_map = None;
1920 }
1921 if !delta.npcs.is_empty() {
1922 self.npcs = delta.npcs.clone();
1923 }
1924 if !delta.quest_log.is_empty() {
1925 self.quest_log = delta.quest_log.clone();
1926 }
1927 if !delta.interactables.is_empty() {
1928 self.interactables = delta.interactables.clone();
1929 }
1930 if let Some(combat) = &delta.combat {
1931 self.apply_combat_hud(combat);
1932 let stacks = self.inventory_stacks.clone();
1933 self.sync_inventory_from_stacks(&stacks);
1934 } else {
1935 self.refresh_inventory_ui();
1936 }
1937 }
1938
1939 pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
1941 let (px, py) = self.player_position();
1942 let mut out = Vec::new();
1943 for npc in &self.npcs {
1944 let Some(eid) = npc.entity_id else {
1945 continue;
1946 };
1947 let alive = npc
1948 .life_state
1949 .is_none_or(|s| s == LifeState::Alive);
1950 let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
1951 if alive && has_hp {
1952 out.push((eid, npc.label.clone()));
1953 }
1954 }
1955 out.sort_by(|(a_id, a_label), (b_id, b_label)| {
1956 let dist = |id: EntityId| {
1957 self.entities
1958 .iter()
1959 .find(|e| e.id == id)
1960 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
1961 .unwrap_or(f32::MAX)
1962 };
1963 dist(*a_id)
1964 .partial_cmp(&dist(*b_id))
1965 .unwrap_or(std::cmp::Ordering::Equal)
1966 .then_with(|| a_label.cmp(b_label))
1967 .then_with(|| a_id.cmp(b_id))
1968 });
1969 out
1970 }
1971
1972 pub fn refresh_combat_target_label(&mut self) {
1973 let Some(id) = self.combat_target else {
1974 return;
1975 };
1976 if let Some((_, label)) = self
1977 .combat_candidates()
1978 .into_iter()
1979 .find(|(eid, _)| *eid == id)
1980 {
1981 self.combat_target_label = Some(label);
1982 } else if let Some(label) = self.entities.iter().find(|e| e.id == id).map(|e| e.label.clone())
1983 {
1984 self.combat_target_label = Some(label);
1985 }
1986 }
1987
1988 pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
1989 self.quest_log
1990 .iter()
1991 .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
1992 .collect()
1993 }
1994
1995 pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
1996 self.quest_log
1997 .iter()
1998 .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
1999 .or_else(|| {
2000 self.quest_log
2001 .iter()
2002 .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
2003 })
2004 }
2005
2006 pub fn nearest_interact_target(&self) -> Option<String> {
2008 let (px, py) = self.player_position();
2009 let inside = self.effective_inside_building();
2010
2011 #[derive(Clone, Copy, PartialEq, Eq)]
2012 enum Kind {
2013 Npc,
2014 QuestBoard,
2015 ExitDoor,
2016 EnterDoor,
2017 Well,
2018 Water,
2019 }
2020
2021 fn kind_priority(kind: Kind) -> u8 {
2022 match kind {
2023 Kind::Npc => 0,
2024 Kind::QuestBoard => 1,
2025 Kind::ExitDoor => 2,
2026 Kind::EnterDoor => 3,
2027 Kind::Well => 4,
2028 Kind::Water => 5,
2029 }
2030 }
2031
2032 let mut best: Option<(f32, Kind, String)> = None;
2033
2034 let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
2035 if dist > max {
2036 return;
2037 }
2038 let replace = match best {
2039 None => true,
2040 Some((bd, _bk, _)) if dist < bd - 0.05 => true,
2041 Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
2042 kind_priority(kind) < kind_priority(bk)
2043 }
2044 _ => false,
2045 };
2046 if replace {
2047 best = Some((dist, kind, id));
2048 }
2049 };
2050
2051 for npc in &self.npcs {
2052 consider(
2053 distance(px, py, npc.x, npc.y),
2054 INTERACTION_RADIUS_M,
2055 Kind::Npc,
2056 npc.id.clone(),
2057 );
2058 }
2059
2060 for door in &self.doors {
2061 if let Some(ref bid) = inside {
2062 if door.building_id != *bid {
2063 continue;
2064 }
2065 let is_exit = door.portal.is_some();
2066 let max = if is_exit {
2067 INTERACTION_RADIUS_M
2068 } else {
2069 DOOR_INTERACTION_RADIUS_M
2070 };
2071 let kind = if is_exit {
2072 Kind::ExitDoor
2073 } else {
2074 Kind::EnterDoor
2075 };
2076 consider(
2077 distance(px, py, door.x, door.y),
2078 max,
2079 kind,
2080 door.id.clone(),
2081 );
2082 continue;
2083 }
2084 consider(
2085 distance(px, py, door.x, door.y),
2086 DOOR_INTERACTION_RADIUS_M,
2087 Kind::EnterDoor,
2088 door.id.clone(),
2089 );
2090 }
2091
2092 if inside.is_none() {
2093 for inter in &self.interactables {
2094 if inter.kind == "quest_board" {
2095 consider(
2096 distance(px, py, inter.x, inter.y),
2097 QUEST_BOARD_INTERACTION_RADIUS_M,
2098 Kind::QuestBoard,
2099 inter.id.clone(),
2100 );
2101 }
2102 }
2103 for building in &self.buildings {
2104 if !building.tags.iter().any(|t| t == "well") {
2105 continue;
2106 }
2107 consider(
2108 distance(px, py, building.x, building.y),
2109 INTERACTION_RADIUS_M,
2110 Kind::Well,
2111 building.id.clone(),
2112 );
2113 }
2114 if self.in_shallow_water() {
2115 consider(0.0, INTERACTION_RADIUS_M, Kind::Water, "water_source".into());
2116 }
2117 }
2118
2119 best.map(|(_, _, id)| id)
2120 }
2121
2122 pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
2124 if self.effective_inside_building().is_some() {
2125 return None;
2126 }
2127 let (px, py) = self.player_position();
2128 self.interactables
2129 .iter()
2130 .filter(|i| i.kind == "quest_board")
2131 .map(|i| {
2132 let label = if i.label.is_empty() {
2133 "Quest board".to_string()
2134 } else {
2135 i.label.clone()
2136 };
2137 (label, distance(px, py, i.x, i.y))
2138 })
2139 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
2140 }
2141
2142 pub fn template_display_name(&self, template_id: &str) -> String {
2144 self.inventory_hints
2145 .get(template_id)
2146 .map(|h| h.display_name.clone())
2147 .filter(|n| !n.is_empty())
2148 .unwrap_or_else(|| humanize_template_id(template_id))
2149 }
2150
2151 pub fn placed_container_public_label(
2153 &self,
2154 c: &flatland_protocol::PlacedContainerView,
2155 ) -> String {
2156 let is_owner = match (self.character_id, c.owner_character_id) {
2157 (Some(me), Some(owner)) => me == owner,
2158 _ => false,
2159 };
2160 if is_owner {
2161 c.display_name.clone()
2162 } else {
2163 self.template_display_name(&c.template_id)
2164 }
2165 }
2166
2167 pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
2169 let mut out = Vec::new();
2170 for stack in &self.inventory_stacks {
2171 if stack.template_id == KEY_TEMPLATE {
2172 out.push(KeychainEntry {
2173 stack: stack.clone(),
2174 stowed: false,
2175 });
2176 }
2177 }
2178 for stack in &self.keychain_stacks {
2179 if stack.template_id == KEY_TEMPLATE {
2180 out.push(KeychainEntry {
2181 stack: stack.clone(),
2182 stowed: true,
2183 });
2184 }
2185 }
2186 out
2187 }
2188
2189 pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
2191 if stack.template_id != KEY_TEMPLATE {
2192 return None;
2193 }
2194 if let Some(name) = stack
2195 .props
2196 .get(PROP_OPENS_CONTAINER_NAME)
2197 .filter(|n| !n.is_empty())
2198 {
2199 return Some(name.clone());
2200 }
2201 let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
2202 self.container_name_for_lock_id(opens)
2203 }
2204
2205 pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
2207 if stack.template_id == KEY_TEMPLATE {
2208 self.template_display_name(KEY_TEMPLATE)
2209 } else {
2210 stack
2211 .display_name
2212 .clone()
2213 .unwrap_or_else(|| stack.template_id.clone())
2214 }
2215 }
2216
2217 pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
2219 if stack.template_id != KEY_TEMPLATE {
2220 return String::new();
2221 }
2222 match self.key_pair_chest_label(stack) {
2223 Some(chest) if self.key_drop_blocked(stack) => {
2224 format!(" [key for {chest} — can't drop while locked]")
2225 }
2226 Some(chest) => format!(" [key for {chest}]"),
2227 None => " [key — unpaired]".into(),
2228 }
2229 }
2230
2231 pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
2233 for c in &self.placed_containers {
2234 if c.lock_id.as_deref() == Some(lock) {
2235 return Some(c.display_name.clone());
2236 }
2237 }
2238 Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
2239 self.worn.values().find_map(|worn| {
2240 Self::container_name_in_stacks(std::slice::from_ref(worn), lock)
2241 })
2242 })
2243 }
2244
2245 pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
2247 if stack.template_id != KEY_TEMPLATE {
2248 return false;
2249 }
2250 let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
2251 return false;
2252 };
2253 for c in &self.placed_containers {
2254 if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
2255 return true;
2256 }
2257 }
2258 if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
2259 return true;
2260 }
2261 self.worn.values().any(|worn| {
2262 Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens)
2263 })
2264 }
2265
2266 fn container_name_in_stacks(
2267 stacks: &[flatland_protocol::ItemStack],
2268 lock: &str,
2269 ) -> Option<String> {
2270 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
2271 for s in stacks {
2272 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
2273 return Some(GameState::stack_container_label(s));
2274 }
2275 if let Some(name) = walk(&s.contents, lock) {
2276 return Some(name);
2277 }
2278 }
2279 None
2280 }
2281 walk(stacks, lock)
2282 }
2283
2284 fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
2285 stack
2286 .props
2287 .get(PROP_CUSTOM_NAME)
2288 .cloned()
2289 .or_else(|| stack.display_name.clone())
2290 .unwrap_or_else(|| stack.template_id.clone())
2291 }
2292
2293 fn has_locked_container_with_lock(
2294 stacks: &[flatland_protocol::ItemStack],
2295 lock: &str,
2296 ) -> bool {
2297 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
2298 for s in stacks {
2299 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
2300 return true;
2301 }
2302 if walk(&s.contents, lock) {
2303 return true;
2304 }
2305 }
2306 false
2307 }
2308 walk(stacks, lock)
2309 }
2310
2311 fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
2312 if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
2313 return Some(stack.clone());
2314 }
2315 for worn in self.worn.values() {
2316 if worn.item_instance_id == Some(instance_id) {
2317 return Some(worn.clone());
2318 }
2319 if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
2320 return Some(stack.clone());
2321 }
2322 }
2323 None
2324 }
2325
2326 pub fn location_context_lines(&self) -> Vec<ContextLine> {
2328 let (px, py) = self.player_position();
2329 let inside = self.effective_inside_building();
2330 let mut lines = Vec::new();
2331
2332 if let Some(kind) = self.terrain_at(px, py) {
2333 lines.push(ContextLine {
2334 on_top: true,
2335 text: format!("Terrain: {}", terrain_kind_label(kind)),
2336 });
2337 }
2338
2339 if let Some(id) = inside.as_ref() {
2340 if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
2341 lines.push(ContextLine {
2342 on_top: true,
2343 text: format!("Inside: {}", b.label),
2344 });
2345 }
2346 }
2347
2348 let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
2349
2350 for node in &self.resource_nodes {
2351 let dist = distance(px, py, node.x, node.y);
2352 if dist > NEARBY_SCAN_M {
2353 continue;
2354 }
2355 let on_top = dist <= ON_TOP_RADIUS_M;
2356 let state = match node.state {
2357 flatland_protocol::ResourceNodeState::Available => " — f harvest",
2358 flatland_protocol::ResourceNodeState::Harvesting => " (being harvested)",
2359 flatland_protocol::ResourceNodeState::Cooldown => " (depleted)",
2360 };
2361 let prefix = if on_top { "On" } else { "Near" };
2362 nearby.push((
2363 dist,
2364 ContextLine {
2365 on_top,
2366 text: format!(
2367 "{prefix}: {} ({dist:.1}m){state}",
2368 node.label
2369 ),
2370 },
2371 ));
2372 }
2373
2374 for drop in &self.ground_drops {
2375 let dist = distance(px, py, drop.x, drop.y);
2376 if dist > INTERACTION_RADIUS_M {
2377 continue;
2378 }
2379 let on_top = dist <= ON_TOP_RADIUS_M;
2380 let name = self.template_display_name(&drop.template_id);
2381 let prefix = if on_top { "On" } else { "Near" };
2382 let qty = if drop.quantity > 1 {
2383 format!(" ×{}", drop.quantity)
2384 } else {
2385 String::new()
2386 };
2387 nearby.push((
2388 dist,
2389 ContextLine {
2390 on_top,
2391 text: format!(
2392 "{prefix}: {name}{qty} ({dist:.1}m) — f pickup"
2393 ),
2394 },
2395 ));
2396 }
2397
2398 for c in &self.placed_containers {
2399 let dist = distance(px, py, c.x, c.y);
2400 if dist > CONTAINER_RANGE_M {
2401 continue;
2402 }
2403 let on_top = dist <= ON_TOP_RADIUS_M;
2404 let name = self.placed_container_public_label(c);
2405 let lock = if c.locked { " [locked]" } else { "" };
2406 let prefix = if on_top { "On" } else { "Near" };
2407 nearby.push((
2408 dist,
2409 ContextLine {
2410 on_top,
2411 text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
2412 },
2413 ));
2414 }
2415
2416 for npc in &self.npcs {
2417 let dist = distance(px, py, npc.x, npc.y);
2418 if dist > NEARBY_SCAN_M {
2419 continue;
2420 }
2421 let on_top = dist <= ON_TOP_RADIUS_M;
2422 let prefix = if on_top { "On" } else { "Near" };
2423 nearby.push((
2424 dist,
2425 ContextLine {
2426 on_top,
2427 text: format!(
2428 "{prefix}: {} ({dist:.1}m) — f talk",
2429 npc.label
2430 ),
2431 },
2432 ));
2433 }
2434
2435 for door in &self.doors {
2436 let dist = distance(px, py, door.x, door.y);
2437 if dist > DOOR_INTERACTION_RADIUS_M {
2438 continue;
2439 }
2440 let building = self
2441 .buildings
2442 .iter()
2443 .find(|b| b.id == door.building_id)
2444 .map(|b| b.label.as_str())
2445 .unwrap_or(door.building_id.as_str());
2446 let action = if inside.is_some() && door.portal.is_some() {
2447 "exit"
2448 } else {
2449 "enter"
2450 };
2451 nearby.push((
2452 dist,
2453 ContextLine {
2454 on_top: dist <= ON_TOP_RADIUS_M,
2455 text: format!("{building} door ({dist:.1}m) — f {action}"),
2456 },
2457 ));
2458 }
2459
2460 if inside.is_none() {
2461 for inter in &self.interactables {
2462 if inter.kind != "quest_board" {
2463 continue;
2464 }
2465 let dist = distance(px, py, inter.x, inter.y);
2466 if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
2467 continue;
2468 }
2469 let on_top = dist <= ON_TOP_RADIUS_M;
2470 let prefix = if on_top { "On" } else { "Near" };
2471 let label = if inter.label.is_empty() {
2472 "Quest board".to_string()
2473 } else {
2474 inter.label.clone()
2475 };
2476 nearby.push((
2477 dist,
2478 ContextLine {
2479 on_top,
2480 text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
2481 },
2482 ));
2483 }
2484 }
2485
2486 if self.in_shallow_water() {
2487 let already = self
2488 .terrain_at(px, py)
2489 .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
2490 if !already {
2491 nearby.push((
2492 0.0,
2493 ContextLine {
2494 on_top: true,
2495 text: "Shallow water — f fill bottle".into(),
2496 },
2497 ));
2498 } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
2499 line.text.push_str(" — f fill bottle");
2500 }
2501 }
2502
2503 for entity in &self.entities {
2504 if entity.id == self.entity_id {
2505 continue;
2506 }
2507 let dist = distance(px, py, entity.transform.position.x, entity.transform.position.y);
2508 if dist > NEARBY_SCAN_M {
2509 continue;
2510 }
2511 let label = if entity.label.is_empty() {
2512 format!("entity {}", entity.id)
2513 } else {
2514 entity.label.clone()
2515 };
2516 nearby.push((
2517 dist,
2518 ContextLine {
2519 on_top: dist <= ON_TOP_RADIUS_M,
2520 text: format!("Near: {label} ({dist:.1}m)"),
2521 },
2522 ));
2523 }
2524
2525 nearby.sort_by(|a, b| {
2526 a.0.partial_cmp(&b.0)
2527 .unwrap_or(std::cmp::Ordering::Equal)
2528 .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
2529 });
2530 lines.extend(nearby.into_iter().map(|(_, l)| l));
2531
2532 if lines.is_empty() {
2533 lines.push(ContextLine {
2534 on_top: false,
2535 text: "(nothing notable nearby)".into(),
2536 });
2537 }
2538
2539 lines
2540 }
2541}
2542
2543#[derive(Debug, Clone)]
2545pub struct ContextLine {
2546 pub on_top: bool,
2547 pub text: String,
2548}
2549
2550const ON_TOP_RADIUS_M: f32 = 0.65;
2551const NEARBY_SCAN_M: f32 = 5.0;
2552
2553fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
2554 use flatland_protocol::TerrainKindView;
2555 match kind {
2556 TerrainKindView::Grass => "Grass",
2557 TerrainKindView::Hill => "Hills",
2558 TerrainKindView::Bog => "Bog",
2559 TerrainKindView::ShallowWater => "Shallow water",
2560 TerrainKindView::DeepWater => "Deep water",
2561 TerrainKindView::Trail => "Trail",
2562 TerrainKindView::Rock => "Rock",
2563 }
2564}
2565
2566fn humanize_template_id(template_id: &str) -> String {
2567 template_id
2568 .split('_')
2569 .map(|word| {
2570 let mut chars = word.chars();
2571 match chars.next() {
2572 None => String::new(),
2573 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
2574 }
2575 })
2576 .collect::<Vec<_>>()
2577 .join(" ")
2578}
2579
2580
2581const HARVEST_RANGE_M: f32 = 1.5;
2583
2584pub struct GameClient<S: PlayConnection> {
2585 session: S,
2586 seq: Seq,
2587 pub state: GameState,
2588 last_move_forward: f32,
2589 last_move_strafe: f32,
2590}
2591
2592impl<S: PlayConnection> GameClient<S> {
2593 pub fn new(session: S) -> Self {
2594 let session_id = session.session_id();
2595 let entity_id = session.entity_id();
2596 Self {
2597 session,
2598 seq: 0,
2599 last_move_forward: 0.0,
2600 last_move_strafe: 0.0,
2601 state: GameState {
2602 session_id,
2603 entity_id,
2604 character_id: None,
2605 tick: 0,
2606 chunk_rev: 0,
2607 content_rev: 0,
2608 entities: Vec::new(),
2609 player: None,
2610 resource_nodes: Vec::new(),
2611 ground_drops: Vec::new(),
2612 placed_containers: Vec::new(),
2613 buildings: Vec::new(),
2614 doors: Vec::new(),
2615 interior_map: None,
2616 npcs: Vec::new(),
2617 blueprints: Vec::new(),
2618 world_width_m: 0.0,
2619 world_height_m: 0.0,
2620 terrain_zones: Vec::new(),
2621 z_platforms: Vec::new(),
2622 z_transitions: Vec::new(),
2623 world_clock: flatland_protocol::WorldClock::default(),
2624 inventory: std::collections::HashMap::new(),
2625 inventory_hints: std::collections::HashMap::new(),
2626 logs: VecDeque::new(),
2627 intents_sent: 0,
2628 ticks_received: 0,
2629 connected: false,
2630 disconnect_reason: None,
2631 show_stats: false,
2632 show_craft_menu: false,
2633 craft_menu_index: 0,
2634 craft_batch_quantity: 1,
2635 show_shop_menu: false,
2636 shop_catalog: None,
2637 shop_tab: ShopTab::default(),
2638 shop_menu_index: 0,
2639 shop_quantity: 1,
2640 shop_trade_log: VecDeque::new(),
2641 show_npc_verb_menu: false,
2642 npc_verb_target: None,
2643 npc_verb_index: 0,
2644 show_npc_chat: false,
2645 npc_chat: None,
2646 show_inventory_menu: false,
2647 inventory_menu_index: 0,
2648 show_move_picker: false,
2649 show_rename_prompt: false,
2650 rename_buffer: String::new(),
2651 move_picker_index: 0,
2652 move_picker: None,
2653 show_destroy_picker: false,
2654 destroy_confirm_pending: false,
2655 destroy_picker: None,
2656 combat_target: None,
2657 combat_target_label: None,
2658 in_combat: false,
2659 auto_attack: true,
2660 combat_has_los: false,
2661 attack_cd_ticks: 0,
2662 gcd_ticks: 0,
2663 weapon_ability_id: "unarmed".into(),
2664 mainhand_template_id: None,
2665 mainhand_label: None,
2666 worn: BTreeMap::new(),
2667 carry_mass: 0.0,
2668 carry_mass_max: 0.0,
2669 encumbrance: flatland_protocol::EncumbranceState::Light,
2670 inventory_stacks: Vec::new(),
2671 keychain_stacks: Vec::new(),
2672 combat_target_detail: None,
2673 cast_progress: None,
2674 ability_cooldowns: Vec::new(),
2675 blocking_active: false,
2676 max_target_slots: 1,
2677 combat_slots: Vec::new(),
2678 rotation_presets: Vec::new(),
2679 show_loadout_menu: false,
2680 show_keychain_menu: false,
2681 keychain_menu_index: 0,
2682 show_rotation_editor: false,
2683 loadout_menu_index: 0,
2684 rotation_editor: RotationEditorState::default(),
2685 harvest_in_progress: false,
2686 harvest_started_at: None,
2687 pending_craft_ack: None,
2688 quest_log: Vec::new(),
2689 interactables: Vec::new(),
2690 show_quest_offer: false,
2691 pending_quest_offer: None,
2692 show_quest_menu: false,
2693 quest_menu_index: 0,
2694 quest_withdraw_confirm: false,
2695 },
2696 }
2697 }
2698
2699 pub fn entity_id(&self) -> EntityId {
2700 self.state.entity_id
2701 }
2702
2703 pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
2704 if self.state.connected {
2705 return Ok(());
2706 }
2707
2708 loop {
2709 match self.session.next_event().await {
2710 Some(SessionEvent::Welcome {
2711 session_id,
2712 entity_id,
2713 snapshot,
2714 }) => {
2715 self.state.restore_from_welcome(session_id, entity_id, &snapshot);
2716 self.state.push_log(format!(
2717 "Connected — session {session_id}, entity {entity_id}"
2718 ));
2719 return Ok(());
2720 }
2721 Some(SessionEvent::Disconnected { .. }) => {
2722 anyhow::bail!("disconnected before welcome");
2723 }
2724 Some(_) => continue,
2725 None => anyhow::bail!("session closed before welcome"),
2726 }
2727 }
2728 }
2729
2730 pub fn drain_events(&mut self) {
2732 while let Some(event) = self.session.try_next_event() {
2733 if self.handle_event_sync(event).is_err() {
2734 break;
2735 }
2736 }
2737 }
2738
2739 pub async fn next_event(&mut self) -> Option<SessionEvent> {
2741 self.session.next_event().await
2742 }
2743
2744 pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
2745 self.handle_event_sync(event)
2746 }
2747
2748 fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
2749 match event {
2750 SessionEvent::Welcome {
2751 session_id,
2752 entity_id,
2753 snapshot,
2754 } => {
2755 let resumed = self.state.connected;
2756 self.state
2757 .restore_from_welcome(session_id, entity_id, &snapshot);
2758 if resumed {
2759 self.state.push_log(format!(
2760 "Session restored — session {session_id}, entity {entity_id}"
2761 ));
2762 }
2763 }
2764 SessionEvent::ContentUpdated { snapshot } => {
2765 self.state.apply_snapshot_fields(&snapshot, self.state.entity_id);
2766 self.state.push_log(format!(
2767 "World updated (content rev {})",
2768 snapshot.content_rev
2769 ));
2770 }
2771 SessionEvent::Tick(delta) => {
2772 self.state.apply_tick_fields(&delta, self.state.entity_id);
2773 self.state.ticks_received += 1;
2774 }
2775 SessionEvent::IntentAck {
2776 entity_id,
2777 seq,
2778 tick,
2779 } => {
2780 crate::harvest_trace!(
2781 entity_id,
2782 seq,
2783 tick,
2784 "client received intent ack"
2785 );
2786 if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
2787 if *craft_seq == seq {
2788 let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
2789 if batches > 1 {
2790 self.state
2791 .push_log(format!("Crafting {label} ×{batches}…"));
2792 } else {
2793 self.state.push_log(format!("Crafting {label}…"));
2794 }
2795 }
2796 }
2797 }
2798 SessionEvent::Chat(msg) => {
2799 let label = match msg.channel {
2800 flatland_protocol::ChatChannel::Nearby => "nearby",
2801 flatland_protocol::ChatChannel::WhisperStone => "whisper",
2802 };
2803 self.state.push_log(format!(
2804 "[{label}] {}: {}",
2805 msg.from_name, msg.text
2806 ));
2807 }
2808 SessionEvent::HarvestResult(result) => {
2809 self.state.clear_harvest_state();
2810 crate::harvest_trace!(
2811 entity_id = self.state.entity_id,
2812 node_id = %result.node_id,
2813 template = %result.item_template,
2814 quantity = result.quantity,
2815 client_tick = self.state.tick,
2816 "client applied harvest result"
2817 );
2818 self.state.push_log(format!(
2819 "Harvested {} x{} (on the ground — press P to pick up)",
2820 result.item_template, result.quantity
2821 ));
2822 }
2823 SessionEvent::CraftResult(result) => {
2824 for stack in &result.consumed {
2825 if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
2826 *qty = qty.saturating_sub(stack.quantity);
2827 if *qty == 0 {
2828 self.state.inventory.remove(&stack.template_id);
2829 }
2830 }
2831 }
2832 for stack in &result.outputs {
2833 *self
2834 .state
2835 .inventory
2836 .entry(stack.template_id.clone())
2837 .or_insert(0) += stack.quantity;
2838 }
2839 if let Some(output) = result.outputs.first() {
2840 if result.batch_total > 1 {
2841 self.state.push_log(format!(
2842 "Crafted {} x{} ({}/{})",
2843 output.template_id,
2844 output.quantity,
2845 result.batch_index,
2846 result.batch_total
2847 ));
2848 } else {
2849 self.state.push_log(format!(
2850 "Crafted {} x{}",
2851 output.template_id, output.quantity
2852 ));
2853 }
2854 } else {
2855 self.state.push_log(format!("Craft finished: {}", result.blueprint_id));
2856 }
2857 }
2858 SessionEvent::Death(notice) => {
2859 self.state.clear_harvest_state();
2860 self.state.push_log(notice.message.clone());
2861 self.state.push_log(format!(
2862 "Respawned at ({:.1}, {:.1})",
2863 notice.respawn_x, notice.respawn_y
2864 ));
2865 }
2866 SessionEvent::Interaction(notice) => {
2867 if notice.message.starts_with("Harvest failed:") {
2868 self.state.clear_harvest_state();
2869 }
2870 if notice.message.starts_with("Can't do that:") {
2871 self.state.pending_craft_ack = None;
2872 }
2873 if notice.message.starts_with("Cast failed:") {
2874 self.state.cast_progress = None;
2875 }
2876 if notice.message.contains("slain the") {
2877 self.state.combat_target = None;
2878 self.state.combat_target_label = None;
2879 }
2880 self.state.apply_interaction_notice(¬ice);
2881 self.state.push_log(notice.message.clone());
2882 }
2883 SessionEvent::ShopOpened(catalog) => {
2884 self.state.apply_shop_catalog(catalog);
2885 }
2886 SessionEvent::NpcTalkOpened(opened) => {
2887 let label = opened.npc_label.clone();
2888 self.state.show_npc_chat = true;
2889 self.state.npc_chat = Some(NpcChatState {
2890 npc_id: opened.npc_id,
2891 npc_label: opened.npc_label,
2892 lines: vec![format!("{label}: {}", opened.greeting)],
2893 input: String::new(),
2894 pending: false,
2895 });
2896 }
2897 SessionEvent::NpcTalkPending(_) => {
2898 if let Some(chat) = self.state.npc_chat.as_mut() {
2899 chat.pending = true;
2900 }
2901 }
2902 SessionEvent::NpcTalkReply(reply) => {
2903 if let Some(chat) = self.state.npc_chat.as_mut() {
2904 if chat.npc_id == reply.npc_id {
2905 chat.pending = false;
2906 chat.lines
2907 .push(format!("{}: {}", chat.npc_label, reply.line));
2908 }
2909 }
2910 }
2911 SessionEvent::NpcTalkClosed(closed) => {
2912 if self
2913 .state
2914 .npc_chat
2915 .as_ref()
2916 .is_some_and(|c| c.npc_id == closed.npc_id)
2917 {
2918 self.state.show_npc_chat = false;
2919 self.state.npc_chat = None;
2920 }
2921 }
2922 SessionEvent::NpcTalkError(err) => {
2923 self.state.push_log(format!("Talk failed: {}", err.reason));
2924 if let Some(chat) = self.state.npc_chat.as_mut() {
2925 chat.pending = false;
2926 }
2927 }
2928 SessionEvent::UseResult(result) => {
2929 if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
2930 *qty = qty.saturating_sub(1);
2931 if *qty == 0 {
2932 self.state.inventory.remove(&result.template_id);
2933 }
2934 }
2935 let mut parts = Vec::new();
2936 if result.health_restored > 0.0 {
2937 parts.push(format!("+{:.0} health", result.health_restored));
2938 }
2939 if result.mana_restored > 0.0 {
2940 parts.push(format!("+{:.0} mana", result.mana_restored));
2941 }
2942 if result.hunger_restored > 0.0 {
2943 parts.push(format!("+{:.0} hunger", result.hunger_restored));
2944 }
2945 if result.thirst_restored > 0.0 {
2946 parts.push(format!("+{:.0} thirst", result.thirst_restored));
2947 }
2948 for id in &result.cleared_dot_ids {
2949 parts.push(format!("cleared {id}"));
2950 }
2951 let detail = if parts.is_empty() {
2952 String::new()
2953 } else {
2954 format!(" ({})", parts.join(", "))
2955 };
2956 self.state.push_log(format!("Consumed {}{detail}", result.template_id));
2957 }
2958 SessionEvent::QuestOffer(offer) => {
2959 self.state.pending_quest_offer = Some(offer.clone());
2960 self.state.show_quest_offer = true;
2961 self.state.push_log(format!("Quest offered: {}", offer.title));
2962 }
2963 SessionEvent::QuestAccepted(notice) => {
2964 self.state.show_quest_offer = false;
2965 self.state.pending_quest_offer = None;
2966 self.state.push_log(notice.message);
2967 }
2968 SessionEvent::QuestWithdrawn(notice) => {
2969 self.state.show_quest_menu = false;
2970 self.state.quest_withdraw_confirm = false;
2971 self.state.push_log(notice.message);
2972 }
2973 SessionEvent::QuestStepCompleted(notice) => {
2974 self.state.push_log(notice.message);
2975 }
2976 SessionEvent::QuestCompleted(notice) => {
2977 self.state.push_log(notice.message);
2978 }
2979 SessionEvent::Disconnected { reason } => {
2980 self.state.clear_harvest_state();
2981 self.state.connected = false;
2982 self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
2983 if let Some(r) = &self.state.disconnect_reason {
2984 self.state.push_log(format!("Disconnected: {r}"));
2985 } else {
2986 self.state.push_log("Disconnected from server");
2987 }
2988 }
2989 }
2990 Ok(())
2991 }
2992
2993 pub fn is_connected(&self) -> bool {
2994 self.state.connected
2995 }
2996
2997 pub fn close_overlays(&mut self) {
2998 self.state.show_stats = false;
2999 self.state.show_craft_menu = false;
3000 self.state.show_shop_menu = false;
3001 self.state.shop_catalog = None;
3002 self.state.show_npc_verb_menu = false;
3003 self.state.npc_verb_target = None;
3004 self.state.show_npc_chat = false;
3005 self.state.npc_chat = None;
3006 self.state.show_inventory_menu = false;
3007 self.state.show_loadout_menu = false;
3008 self.state.show_rotation_editor = false;
3009 self.state.rotation_editor.reset();
3010 self.state.show_rename_prompt = false;
3011 self.state.rename_buffer.clear();
3012 self.state.show_move_picker = false;
3013 self.state.move_picker = None;
3014 self.state.show_destroy_picker = false;
3015 self.state.destroy_confirm_pending = false;
3016 self.state.destroy_picker = None;
3017 self.state.show_quest_offer = false;
3018 self.state.pending_quest_offer = None;
3019 self.state.show_quest_menu = false;
3020 self.state.quest_withdraw_confirm = false;
3021 }
3022
3023 pub fn back_on_esc(&mut self) -> bool {
3025 if self.state.show_rename_prompt {
3026 self.cancel_rename_prompt();
3027 return true;
3028 }
3029 if self.state.show_destroy_picker {
3030 if self.state.destroy_confirm_pending {
3031 self.cancel_destroy_confirm();
3032 } else {
3033 self.close_destroy_picker();
3034 }
3035 return true;
3036 }
3037 if self.state.show_move_picker {
3038 self.close_move_picker();
3039 return true;
3040 }
3041 if self.state.show_rotation_editor {
3042 match self.state.rotation_editor.mode {
3043 RotationEditorMode::List => {
3044 self.state.show_rotation_editor = false;
3045 self.state.rotation_editor.reset();
3046 }
3047 RotationEditorMode::EditLabel => {
3048 self.state.rotation_editor.label_buffer.clear();
3049 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
3050 }
3051 RotationEditorMode::PickAbility => {
3052 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
3053 }
3054 RotationEditorMode::EditSequence => {
3055 self.state.rotation_editor.draft = None;
3056 self.state.rotation_editor.mode = RotationEditorMode::List;
3057 }
3058 }
3059 return true;
3060 }
3061 if self.state.show_inventory_menu {
3062 self.close_inventory_menu();
3063 return true;
3064 }
3065 if self.state.show_craft_menu {
3066 self.close_craft_menu();
3067 return true;
3068 }
3069 if self.state.show_shop_menu {
3070 self.close_shop_menu();
3071 return true;
3072 }
3073 if self.state.show_npc_chat {
3074 return false;
3076 }
3077 if self.state.show_npc_verb_menu {
3078 self.state.show_npc_verb_menu = false;
3079 self.state.npc_verb_target = None;
3080 return true;
3081 }
3082 if self.state.show_quest_offer {
3083 self.state.show_quest_offer = false;
3084 self.state.pending_quest_offer = None;
3085 return true;
3086 }
3087 if self.state.show_quest_menu {
3088 if self.state.quest_withdraw_confirm {
3089 self.state.quest_withdraw_confirm = false;
3090 } else {
3091 self.state.show_quest_menu = false;
3092 }
3093 return true;
3094 }
3095 if self.state.show_loadout_menu {
3096 self.state.show_loadout_menu = false;
3097 return true;
3098 }
3099 if self.state.show_stats {
3100 self.state.show_stats = false;
3101 return true;
3102 }
3103 false
3104 }
3105
3106 pub fn toggle_stats(&mut self) {
3107 self.state.show_stats = !self.state.show_stats;
3108 if self.state.show_stats {
3109 self.state.show_craft_menu = false;
3110 self.state.show_shop_menu = false;
3111 self.state.shop_catalog = None;
3112 self.state.show_inventory_menu = false;
3113 }
3114 }
3115
3116 pub fn open_inventory_menu(&mut self) {
3117 self.state.show_inventory_menu = true;
3118 self.state.show_craft_menu = false;
3119 self.state.show_shop_menu = false;
3120 self.state.shop_catalog = None;
3121 self.state.show_stats = false;
3122 self.state.show_move_picker = false;
3123 self.state.move_picker = None;
3124 self.state.show_destroy_picker = false;
3125 self.state.destroy_confirm_pending = false;
3126 self.state.destroy_picker = None;
3127 self.state.show_rename_prompt = false;
3128 self.state.rename_buffer.clear();
3129 self.state.clamp_inventory_indices();
3130 }
3131
3132 pub fn close_inventory_menu(&mut self) {
3133 self.state.show_inventory_menu = false;
3134 self.state.show_move_picker = false;
3135 self.state.move_picker = None;
3136 self.state.show_destroy_picker = false;
3137 self.state.destroy_confirm_pending = false;
3138 self.state.destroy_picker = None;
3139 self.state.show_rename_prompt = false;
3140 self.state.rename_buffer.clear();
3141 }
3142
3143 pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
3144 let Some(row) = self.state.inventory_selected_row() else {
3145 anyhow::bail!("inventory empty");
3146 };
3147 if !self.state.row_is_renameable_container(&row) {
3148 anyhow::bail!("only storage containers can be renamed");
3149 }
3150 let current = row
3151 .stack
3152 .display_name
3153 .clone()
3154 .unwrap_or_else(|| row.stack.template_id.clone());
3155 self.state.rename_buffer = current;
3156 self.state.show_rename_prompt = true;
3157 self.state.show_move_picker = false;
3158 self.state.show_destroy_picker = false;
3159 self.state.destroy_confirm_pending = false;
3160 Ok(())
3161 }
3162
3163 pub fn cancel_rename_prompt(&mut self) {
3164 self.state.show_rename_prompt = false;
3165 self.state.rename_buffer.clear();
3166 }
3167
3168 pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
3169 let name = self.state.rename_buffer.trim().to_string();
3170 if name.is_empty() {
3171 anyhow::bail!("name cannot be empty");
3172 }
3173 let Some(row) = self.state.inventory_selected_row() else {
3174 anyhow::bail!("inventory empty");
3175 };
3176 let Some(instance_id) = row.stack.item_instance_id else {
3177 anyhow::bail!("item has no instance id");
3178 };
3179 self.seq += 1;
3180 self.session
3181 .submit_intent(Intent::RenameContainer {
3182 entity_id: self.state.entity_id,
3183 item_instance_id: instance_id,
3184 location: row.from.clone(),
3185 name,
3186 seq: self.seq,
3187 })
3188 .await?;
3189 self.state.intents_sent += 1;
3190 self.state.show_rename_prompt = false;
3191 self.state.rename_buffer.clear();
3192 Ok(())
3193 }
3194
3195 pub fn toggle_inventory_menu(&mut self) {
3196 if self.state.show_inventory_menu {
3197 self.close_inventory_menu();
3198 } else {
3199 self.open_inventory_menu();
3200 }
3201 }
3202
3203 pub fn inventory_menu_move(&mut self, delta: i32) {
3205 if self.state.show_move_picker {
3206 let n = self
3207 .state
3208 .move_picker
3209 .as_ref()
3210 .map(|p| p.options.len())
3211 .unwrap_or(0);
3212 if n == 0 {
3213 return;
3214 }
3215 let idx = self.state.move_picker_index as i32;
3216 self.state.move_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
3217 self.state.clamp_move_picker_quantity();
3218 return;
3219 }
3220 let n = self.state.inventory_selectable_rows().len();
3221 if n == 0 {
3222 return;
3223 }
3224 let idx = self.state.inventory_menu_index as i32;
3225 self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
3226 }
3227
3228 pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
3233 if self.state.show_destroy_picker {
3234 if self.state.destroy_confirm_pending {
3235 return self.confirm_destroy_item().await;
3236 }
3237 return self.request_destroy_confirm();
3238 }
3239 if self.state.show_move_picker {
3240 return self.confirm_move_picker().await;
3241 }
3242 let Some(row) = self.state.inventory_selected_row() else {
3243 anyhow::bail!("inventory empty");
3244 };
3245 if row.is_equip_shell {
3246 let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
3247 anyhow::bail!("not a worn item");
3248 };
3249 return self.equip_worn(slot, None).await;
3250 }
3251 if row.is_chest_shell {
3252 let flatland_protocol::InventoryLocation::Placed { container_id } = row.from else {
3253 anyhow::bail!("not a placed chest");
3254 };
3255 return self.toggle_placed_chest_lock(&container_id).await;
3256 }
3257 let template_id = row.stack.template_id.clone();
3258 let instance_id = row.stack.item_instance_id;
3259 let category = self.state.inventory_item_category(&template_id);
3260 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
3261
3262 if category == Some("weapon") {
3263 return self.equip_mainhand(Some(template_id)).await;
3264 }
3265 if (category == Some("container") || category == Some("armor")) && on_person {
3266 if let Some(inst) = instance_id {
3267 if template_id.contains("chest") {
3268 return self.place_container(inst).await;
3269 }
3270 if let Some(slot) = guess_body_slot(&template_id) {
3274 return self.equip_worn(slot, Some(inst)).await;
3275 }
3276 }
3277 }
3278 if category == Some("consumable") && on_person {
3279 return self.use_item(&template_id).await;
3280 }
3281 self.open_move_picker()
3285 }
3286
3287 pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
3290 let Some(row) = self.state.inventory_selected_row() else {
3291 anyhow::bail!("inventory empty");
3292 };
3293 if row.is_equip_shell {
3294 anyhow::bail!("this is a worn bag — press Enter to unequip it");
3295 }
3296 if row.is_chest_shell {
3297 anyhow::bail!("select the chest and press Enter or l to lock/unlock it");
3298 }
3299 let Some(instance_id) = row.stack.item_instance_id else {
3300 anyhow::bail!("item has no instance id");
3301 };
3302 let options = self.state.move_destinations_for(
3303 &row.from,
3304 row.from_parent_instance_id,
3305 row.stack.item_instance_id,
3306 &row.stack.template_id,
3307 );
3308 let item_label = row
3309 .stack
3310 .display_name
3311 .clone()
3312 .unwrap_or_else(|| row.stack.template_id.clone());
3313 self.state.move_picker = Some(MovePicker {
3314 item_instance_id: instance_id,
3315 from: row.from,
3316 item_label,
3317 template_id: row.stack.template_id.clone(),
3318 stack_quantity: row.stack.quantity,
3319 quantity: row.stack.quantity,
3320 options,
3321 });
3322 self.state.move_picker_index = 0;
3323 self.state.show_move_picker = true;
3324 self.state.show_destroy_picker = false;
3325 self.state.destroy_confirm_pending = false;
3326 self.state.destroy_picker = None;
3327 Ok(())
3328 }
3329
3330 pub fn close_move_picker(&mut self) {
3331 self.state.show_move_picker = false;
3332 self.state.move_picker = None;
3333 }
3334
3335 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
3336 self.state.move_picker_adjust_quantity(delta);
3337 }
3338
3339 pub fn move_picker_set_quantity_max(&mut self) {
3340 self.state.move_picker_set_quantity_max();
3341 }
3342
3343 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
3344 self.state.destroy_picker_adjust_quantity(delta);
3345 }
3346
3347 pub fn destroy_picker_set_quantity_max(&mut self) {
3348 self.state.destroy_picker_set_quantity_max();
3349 }
3350
3351 async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
3352 let Some(picker) = self.state.move_picker.clone() else {
3353 self.close_move_picker();
3354 return Ok(());
3355 };
3356 let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
3357 self.close_move_picker();
3358 return Ok(());
3359 };
3360 match option.kind {
3361 MoveOptionKind::Cancel => {
3362 self.close_move_picker();
3363 }
3364 MoveOptionKind::Drop => {
3365 self.close_move_picker();
3366 if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
3367 if self.state.key_drop_blocked(&stack) {
3368 anyhow::bail!("cannot drop the key while its chest is locked");
3369 }
3370 }
3371 self.drop_item(picker.item_instance_id, picker.from).await?;
3372 self.state.push_log(format!("Dropped {}", picker.item_label));
3373 }
3374 MoveOptionKind::Move {
3375 location,
3376 parent_instance_id,
3377 } => {
3378 self.close_move_picker();
3379 let qty = if picker.quantity >= picker.stack_quantity {
3380 None
3381 } else {
3382 Some(picker.quantity)
3383 };
3384 self.move_item(
3385 picker.item_instance_id,
3386 picker.from,
3387 location,
3388 parent_instance_id,
3389 qty,
3390 )
3391 .await?;
3392 let moved = qty.unwrap_or(picker.stack_quantity);
3393 if moved >= picker.stack_quantity {
3394 self.state.push_log(format!("Moved {}", picker.item_label));
3395 } else {
3396 self.state.push_log(format!(
3397 "Moved {} ×{} of {}",
3398 picker.item_label, moved, picker.stack_quantity
3399 ));
3400 }
3401 }
3402 }
3403 Ok(())
3404 }
3405
3406 pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
3408 let Some(row) = self.state.inventory_selected_row() else {
3409 anyhow::bail!("inventory empty");
3410 };
3411 if row.is_equip_shell {
3412 anyhow::bail!("unequip the bag first (Enter), then drop from your person");
3413 }
3414 if row.is_chest_shell {
3415 anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
3416 }
3417 let Some(inst) = row.stack.item_instance_id else {
3418 anyhow::bail!("item has no instance id");
3419 };
3420 if self.state.key_drop_blocked(&row.stack) {
3421 anyhow::bail!("cannot drop the key while its chest is locked");
3422 }
3423 let label = row
3424 .stack
3425 .display_name
3426 .clone()
3427 .unwrap_or_else(|| row.stack.template_id.clone());
3428 self.drop_item(inst, row.from).await?;
3429 self.state.push_log(format!("Dropped {label}"));
3430 Ok(())
3431 }
3432
3433 pub async fn drop_item(
3434 &mut self,
3435 item_instance_id: uuid::Uuid,
3436 from: flatland_protocol::InventoryLocation,
3437 ) -> anyhow::Result<()> {
3438 self.seq += 1;
3439 self.session
3440 .submit_intent(Intent::DropItem {
3441 entity_id: self.state.entity_id,
3442 item_instance_id,
3443 from,
3444 seq: self.seq,
3445 })
3446 .await?;
3447 self.state.intents_sent += 1;
3448 Ok(())
3449 }
3450
3451 pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
3453 let Some(row) = self.state.inventory_selected_row() else {
3454 anyhow::bail!("inventory empty");
3455 };
3456 if row.is_equip_shell {
3457 anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
3458 }
3459 if row.is_chest_shell {
3460 anyhow::bail!("can't destroy a placed chest from the inventory list");
3461 }
3462 let Some(instance_id) = row.stack.item_instance_id else {
3463 anyhow::bail!("item has no instance id");
3464 };
3465 if self.state.key_drop_blocked(&row.stack) {
3466 anyhow::bail!("cannot destroy the key while its chest is locked");
3467 }
3468 let item_label = row
3469 .stack
3470 .display_name
3471 .clone()
3472 .unwrap_or_else(|| row.stack.template_id.clone());
3473 self.state.destroy_picker = Some(DestroyPicker {
3474 item_instance_id: instance_id,
3475 from: row.from,
3476 item_label,
3477 stack_quantity: row.stack.quantity,
3478 quantity: row.stack.quantity,
3479 });
3480 self.state.destroy_confirm_pending = false;
3481 self.state.show_destroy_picker = true;
3482 self.state.show_move_picker = false;
3483 self.state.move_picker = None;
3484 Ok(())
3485 }
3486
3487 pub fn close_destroy_picker(&mut self) {
3488 self.state.show_destroy_picker = false;
3489 self.state.destroy_confirm_pending = false;
3490 self.state.destroy_picker = None;
3491 }
3492
3493 pub fn cancel_destroy_confirm(&mut self) {
3494 self.state.destroy_confirm_pending = false;
3495 }
3496
3497 pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
3498 if self.state.destroy_picker.is_none() {
3499 self.close_destroy_picker();
3500 return Ok(());
3501 }
3502 self.state.destroy_confirm_pending = true;
3503 Ok(())
3504 }
3505
3506 pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
3507 let Some(picker) = self.state.destroy_picker.clone() else {
3508 self.close_destroy_picker();
3509 return Ok(());
3510 };
3511 let qty = if picker.quantity >= picker.stack_quantity {
3512 None
3513 } else {
3514 Some(picker.quantity)
3515 };
3516 self.destroy_item(picker.item_instance_id, picker.from, qty)
3517 .await?;
3518 let destroyed = qty.unwrap_or(picker.stack_quantity);
3519 if destroyed >= picker.stack_quantity {
3520 self.state
3521 .push_log(format!("Destroyed {}", picker.item_label));
3522 } else {
3523 self.state.push_log(format!(
3524 "Destroyed {} ×{} of {}",
3525 picker.item_label, destroyed, picker.stack_quantity
3526 ));
3527 }
3528 self.close_destroy_picker();
3529 Ok(())
3530 }
3531
3532 pub async fn destroy_item(
3533 &mut self,
3534 item_instance_id: uuid::Uuid,
3535 from: flatland_protocol::InventoryLocation,
3536 quantity: Option<u32>,
3537 ) -> anyhow::Result<()> {
3538 self.seq += 1;
3539 self.session
3540 .submit_intent(Intent::DestroyItem {
3541 entity_id: self.state.entity_id,
3542 item_instance_id,
3543 from,
3544 quantity,
3545 seq: self.seq,
3546 })
3547 .await?;
3548 self.state.intents_sent += 1;
3549 Ok(())
3550 }
3551
3552 pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
3554 if let Some(row) = self.state.inventory_selected_row() {
3555 if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
3556 return self.toggle_placed_chest_lock(container_id).await;
3557 }
3558 }
3559 self.toggle_nearby_chest_lock().await
3560 }
3561
3562 pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
3563 let chest = self
3564 .state
3565 .placed_containers
3566 .iter()
3567 .find(|c| c.id == container_id)
3568 .cloned()
3569 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
3570 let (px, py) = self.state.player_position();
3571 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
3572 anyhow::bail!("too far from {}", chest.display_name);
3573 }
3574 if !chest.accessible && chest.locked {
3575 anyhow::bail!(
3576 "need the matching key for {} (each crafted chest has its own key)",
3577 chest.display_name
3578 );
3579 }
3580 let lock = !chest.locked;
3581 self.set_container_locked(
3582 flatland_protocol::InventoryLocation::Placed {
3583 container_id: chest.id.clone(),
3584 },
3585 lock,
3586 )
3587 .await?;
3588 self.state.push_log(if lock {
3589 format!("Locked {}", chest.display_name)
3590 } else {
3591 format!("Unlocked {}", chest.display_name)
3592 });
3593 Ok(())
3594 }
3595
3596 pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
3598 let chest = self
3599 .state
3600 .nearest_placed_container(CONTAINER_RANGE_M)
3601 .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
3602 self.toggle_placed_chest_lock(&chest.id).await
3603 }
3604
3605 pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
3606 self.equip_mainhand(None).await
3607 }
3608
3609 pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
3610 let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
3611 for slot in slots {
3612 self.equip_worn(slot, None).await?;
3613 }
3614 Ok(())
3615 }
3616
3617 pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
3618 let (px, py) = self.state.player_position();
3619 let nearest = self
3620 .state
3621 .placed_containers
3622 .iter()
3623 .min_by(|a, b| {
3624 let da = (a.x - px).hypot(a.y - py);
3625 let db = (b.x - px).hypot(b.y - py);
3626 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
3627 })
3628 .cloned();
3629 let Some(chest) = nearest else {
3630 anyhow::bail!("no chest nearby");
3631 };
3632 if (chest.x - px).hypot(chest.y - py) > 2.0 {
3633 anyhow::bail!("too far from chest");
3634 }
3635 self.pickup_container(chest.id).await
3636 }
3637
3638 pub async fn equip_worn(
3639 &mut self,
3640 slot: BodySlot,
3641 instance_id: Option<uuid::Uuid>,
3642 ) -> anyhow::Result<()> {
3643 self.seq += 1;
3644 self.session
3645 .submit_intent(Intent::EquipWorn {
3646 entity_id: self.state.entity_id,
3647 slot,
3648 instance_id,
3649 seq: self.seq,
3650 })
3651 .await?;
3652 self.state.intents_sent += 1;
3653 Ok(())
3654 }
3655
3656 pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
3657 self.seq += 1;
3658 self.session
3659 .submit_intent(Intent::PlaceContainer {
3660 entity_id: self.state.entity_id,
3661 item_instance_id,
3662 seq: self.seq,
3663 })
3664 .await?;
3665 self.state.intents_sent += 1;
3666 Ok(())
3667 }
3668
3669 pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
3670 self.seq += 1;
3671 self.session
3672 .submit_intent(Intent::PickupContainer {
3673 entity_id: self.state.entity_id,
3674 container_id,
3675 seq: self.seq,
3676 })
3677 .await?;
3678 self.state.intents_sent += 1;
3679 Ok(())
3680 }
3681
3682 pub async fn move_item(
3683 &mut self,
3684 item_instance_id: uuid::Uuid,
3685 from: flatland_protocol::InventoryLocation,
3686 to: flatland_protocol::InventoryLocation,
3687 to_parent_instance_id: Option<uuid::Uuid>,
3688 quantity: Option<u32>,
3689 ) -> anyhow::Result<()> {
3690 self.seq += 1;
3691 self.session
3692 .submit_intent(Intent::MoveItem {
3693 entity_id: self.state.entity_id,
3694 item_instance_id,
3695 from,
3696 to,
3697 to_parent_instance_id,
3698 quantity,
3699 seq: self.seq,
3700 })
3701 .await?;
3702 self.state.intents_sent += 1;
3703 Ok(())
3704 }
3705
3706 pub async fn set_container_locked(
3707 &mut self,
3708 location: flatland_protocol::InventoryLocation,
3709 locked: bool,
3710 ) -> anyhow::Result<()> {
3711 self.seq += 1;
3712 self.session
3713 .submit_intent(Intent::SetContainerLocked {
3714 entity_id: self.state.entity_id,
3715 location,
3716 locked,
3717 seq: self.seq,
3718 })
3719 .await?;
3720 self.state.intents_sent += 1;
3721 Ok(())
3722 }
3723
3724 pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
3725 if !self.state.is_alive() {
3726 anyhow::bail!("you are dead");
3727 }
3728 self.seq += 1;
3729 self.session
3730 .submit_intent(Intent::Use {
3731 entity_id: self.state.entity_id,
3732 template_id: template_id.to_string(),
3733 seq: self.seq,
3734 })
3735 .await?;
3736 self.state.intents_sent += 1;
3737 Ok(())
3738 }
3739
3740 pub fn open_craft_menu(&mut self) {
3741 self.state.show_craft_menu = true;
3742 self.state.show_shop_menu = false;
3743 self.state.shop_catalog = None;
3744 self.state.show_stats = false;
3745 self.state.show_inventory_menu = false;
3746 if self.state.blueprints.is_empty() {
3747 self.state.craft_menu_index = 0;
3748 self.state.craft_batch_quantity = 1;
3749 return;
3750 }
3751 self.state.craft_menu_index = self
3752 .state
3753 .craft_menu_index
3754 .min(self.state.blueprints.len() - 1);
3755 if let Some(idx) = self
3756 .state
3757 .blueprints
3758 .iter()
3759 .position(|bp| self.state.can_craft_blueprint(bp))
3760 {
3761 self.state.craft_menu_index = idx;
3762 }
3763 self.state.clamp_craft_batch_quantity();
3764 }
3765
3766 pub fn close_craft_menu(&mut self) {
3767 self.state.show_craft_menu = false;
3768 }
3769
3770 pub fn toggle_keychain_menu(&mut self) {
3771 if self.state.show_keychain_menu {
3772 self.close_keychain_menu();
3773 } else {
3774 self.state.show_keychain_menu = true;
3775 self.state.show_craft_menu = false;
3776 self.state.show_shop_menu = false;
3777 self.state.show_inventory_menu = false;
3778 let n = self.state.keychain_entries().len();
3779 if n == 0 {
3780 self.state.keychain_menu_index = 0;
3781 } else {
3782 self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
3783 }
3784 }
3785 }
3786
3787 pub fn close_keychain_menu(&mut self) {
3788 self.state.show_keychain_menu = false;
3789 }
3790
3791 pub fn keychain_menu_move(&mut self, delta: i32) {
3792 let n = self.state.keychain_entries().len();
3793 if n == 0 {
3794 self.state.keychain_menu_index = 0;
3795 return;
3796 }
3797 let idx = self.state.keychain_menu_index as i32 + delta;
3798 self.state.keychain_menu_index =
3799 idx.rem_euclid(n as i32) as usize;
3800 }
3801
3802 pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
3803 if !self.state.is_alive() {
3804 anyhow::bail!("you are dead");
3805 }
3806 let entries = self.state.keychain_entries();
3807 let Some(entry) = entries.get(self.state.keychain_menu_index) else {
3808 anyhow::bail!("nothing selected");
3809 };
3810 let Some(instance_id) = entry.stack.item_instance_id else {
3811 anyhow::bail!("key has no instance id");
3812 };
3813 if entry.stowed {
3814 self.move_item(
3815 instance_id,
3816 flatland_protocol::InventoryLocation::Keychain,
3817 flatland_protocol::InventoryLocation::Root,
3818 None,
3819 Some(1),
3820 )
3821 .await
3822 } else {
3823 self.move_item(
3824 instance_id,
3825 flatland_protocol::InventoryLocation::Root,
3826 flatland_protocol::InventoryLocation::Keychain,
3827 None,
3828 Some(1),
3829 )
3830 .await
3831 }
3832 }
3833
3834 pub fn close_shop_menu(&mut self) {
3835 self.state.show_shop_menu = false;
3836 self.state.shop_catalog = None;
3837 self.state.clear_shop_trade_log();
3838 }
3839
3840 pub fn shop_tab_toggle(&mut self) {
3841 self.state.shop_tab = match self.state.shop_tab {
3842 ShopTab::Buy => ShopTab::Sell,
3843 ShopTab::Sell => ShopTab::Buy,
3844 };
3845 self.state.shop_menu_index = 0;
3846 self.state.clamp_shop_selection();
3847 }
3848
3849 pub fn shop_menu_move(&mut self, delta: i32) {
3850 self.state.shop_menu_move(delta);
3851 }
3852
3853 pub fn shop_quantity_adjust(&mut self, delta: i32) {
3854 self.state.shop_quantity_adjust(delta);
3855 }
3856
3857 pub fn shop_quantity_set_max(&mut self) {
3858 self.state.shop_quantity_set_max();
3859 }
3860
3861 pub fn toggle_quest_menu(&mut self) {
3862 self.state.show_quest_menu = !self.state.show_quest_menu;
3863 if self.state.show_quest_menu {
3864 self.state.quest_menu_index = 0;
3865 self.state.quest_withdraw_confirm = false;
3866 }
3867 }
3868
3869 pub fn quest_menu_move(&mut self, delta: i32) {
3870 let n = self.state.active_quest_entries().len();
3871 if n == 0 {
3872 return;
3873 }
3874 let idx = self.state.quest_menu_index as i32;
3875 self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
3876 }
3877
3878 pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
3879 let Some(offer) = self.state.pending_quest_offer.clone() else {
3880 anyhow::bail!("no quest offer");
3881 };
3882 self.seq += 1;
3883 let seq = self.seq;
3884 self.session
3885 .submit_intent(Intent::AcceptQuest {
3886 entity_id: self.state.entity_id,
3887 quest_id: offer.quest_id,
3888 seq,
3889 })
3890 .await?;
3891 self.state.intents_sent += 1;
3892 Ok(())
3893 }
3894
3895 pub fn quest_offer_decline(&mut self) {
3896 self.state.show_quest_offer = false;
3897 self.state.pending_quest_offer = None;
3898 }
3899
3900 pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
3901 if !self.state.show_quest_menu {
3902 return Ok(());
3903 }
3904 let active: Vec<_> = self
3905 .state
3906 .active_quest_entries()
3907 .into_iter()
3908 .cloned()
3909 .collect();
3910 let Some(entry) = active.get(self.state.quest_menu_index) else {
3911 return Ok(());
3912 };
3913 if self.state.quest_withdraw_confirm {
3914 if !entry.can_withdraw {
3915 anyhow::bail!("quest cannot be withdrawn");
3916 }
3917 self.seq += 1;
3918 let seq = self.seq;
3919 self.session
3920 .submit_intent(Intent::WithdrawQuest {
3921 entity_id: self.state.entity_id,
3922 quest_id: entry.quest_id.clone(),
3923 seq,
3924 })
3925 .await?;
3926 self.state.intents_sent += 1;
3927 self.state.quest_withdraw_confirm = false;
3928 return Ok(());
3929 }
3930 self.seq += 1;
3931 let seq = self.seq;
3932 self.session
3933 .submit_intent(Intent::TrackQuest {
3934 entity_id: self.state.entity_id,
3935 quest_id: entry.quest_id.clone(),
3936 seq,
3937 })
3938 .await?;
3939 self.state.intents_sent += 1;
3940 Ok(())
3941 }
3942
3943 pub fn quest_request_withdraw(&mut self) {
3944 if self.state.show_quest_menu {
3945 self.state.quest_withdraw_confirm = true;
3946 }
3947 }
3948
3949 pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
3950 if !self.state.is_alive() {
3951 anyhow::bail!("you are dead");
3952 }
3953 let Some(catalog) = self.state.shop_catalog.clone() else {
3954 anyhow::bail!("no shop open");
3955 };
3956 self.seq += 1;
3957 let seq = self.seq;
3958 match self.state.shop_tab {
3959 ShopTab::Buy => {
3960 let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
3961 anyhow::bail!("nothing selected");
3962 };
3963 if offer.already_owned {
3964 anyhow::bail!("already owned");
3965 }
3966 self.session
3967 .submit_intent(Intent::ShopBuy {
3968 entity_id: self.state.entity_id,
3969 npc_id: catalog.npc_id.clone(),
3970 offer_id: offer.offer_id.clone(),
3971 quantity: self.state.shop_quantity,
3972 seq,
3973 })
3974 .await?;
3975 }
3976 ShopTab::Sell => {
3977 let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
3978 anyhow::bail!("nothing to sell");
3979 };
3980 self.session
3981 .submit_intent(Intent::ShopSell {
3982 entity_id: self.state.entity_id,
3983 npc_id: catalog.npc_id.clone(),
3984 template_id: line.template_id.clone(),
3985 quantity: self.state.shop_quantity.min(line.quantity),
3986 seq,
3987 })
3988 .await?;
3989 }
3990 }
3991 self.state.intents_sent += 1;
3992 Ok(())
3993 }
3994
3995 pub fn craft_menu_move(&mut self, delta: i32) {
3996 let n = self.state.blueprints.len();
3997 if n == 0 {
3998 return;
3999 }
4000 let idx = self.state.craft_menu_index as i32;
4001 let next = (idx + delta).rem_euclid(n as i32);
4002 self.state.craft_menu_index = next as usize;
4003 self.state.clamp_craft_batch_quantity();
4004 }
4005
4006 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
4007 self.state.craft_batch_adjust_quantity(delta);
4008 }
4009
4010 pub fn craft_batch_set_max(&mut self) {
4011 self.state.craft_batch_set_max();
4012 }
4013
4014 pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
4015 let Some(blueprint) = self
4016 .state
4017 .blueprints
4018 .get(self.state.craft_menu_index)
4019 .cloned()
4020 else {
4021 anyhow::bail!("no blueprints known");
4022 };
4023 if !self.state.can_craft_blueprint(&blueprint) {
4024 let hint = self
4025 .state
4026 .craft_missing_hint(&blueprint)
4027 .unwrap_or_else(|| "missing materials".into());
4028 anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
4029 }
4030 let count = self.state.craft_batch_quantity;
4031 let max = self.state.max_craft_batches(&blueprint);
4032 if max == 0 {
4033 anyhow::bail!("cannot craft {}", blueprint.label);
4034 }
4035 let batches = count.min(max);
4036 self.craft(&blueprint.id, Some(batches)).await?;
4037 self.state.show_craft_menu = false;
4038 Ok(())
4039 }
4040
4041 pub async fn move_by(
4042 &mut self,
4043 forward: f32,
4044 strafe: f32,
4045 vertical: f32,
4046 sprint: bool,
4047 ) -> anyhow::Result<()> {
4048 if !self.state.is_alive() {
4049 anyhow::bail!("you are dead");
4050 }
4051 if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
4052 self.last_move_forward = forward;
4053 self.last_move_strafe = strafe;
4054 }
4055 self.seq += 1;
4056 self.session
4057 .submit_intent(Intent::Move {
4058 entity_id: self.state.entity_id,
4059 forward,
4060 strafe,
4061 vertical,
4062 sprint,
4063 seq: self.seq,
4064 })
4065 .await?;
4066 self.state.intents_sent += 1;
4067 Ok(())
4068 }
4069
4070 pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
4071 if !self.state.connected {
4072 crate::harvest_trace!("harvest_nearest rejected: not connected");
4073 anyhow::bail!("not connected");
4074 }
4075 if !self.state.is_alive() {
4076 crate::harvest_trace!("harvest_nearest rejected: player dead");
4077 anyhow::bail!("you are dead");
4078 }
4079 if self.state.harvest_in_progress {
4080 if self.state.harvest_state_stale() {
4081 self.state.clear_harvest_state();
4082 } else {
4083 anyhow::bail!("already harvesting");
4084 }
4085 }
4086 let (px, py) = self
4087 .state
4088 .player
4089 .as_ref()
4090 .map(|p| (p.transform.position.x, p.transform.position.y))
4091 .unwrap_or((0.0, 0.0));
4092
4093 let available = self
4094 .state
4095 .resource_nodes
4096 .iter()
4097 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
4098 .count();
4099 let node_id = self
4100 .state
4101 .resource_nodes
4102 .iter()
4103 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
4104 .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
4105 .min_by(|a, b| {
4106 let da = distance(px, py, a.x, a.y);
4107 let db = distance(px, py, b.x, b.y);
4108 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
4109 })
4110 .map(|n| n.id.clone());
4111
4112 let Some(node_id) = node_id else {
4113 let has_loot = self.state.ground_drops.iter().any(|d| {
4114 distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M
4115 });
4116 if has_loot {
4117 return self.pickup_nearest().await;
4118 }
4119 anyhow::bail!(
4120 "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
4121 );
4122 };
4123
4124 self.seq += 1;
4125 let seq = self.seq;
4126 crate::harvest_trace!(
4127 entity_id = self.state.entity_id,
4128 node_id = %node_id,
4129 seq,
4130 px,
4131 py,
4132 available_nodes = available,
4133 "submitting harvest intent"
4134 );
4135 self.session
4136 .submit_intent(Intent::Harvest {
4137 entity_id: self.state.entity_id,
4138 node_id,
4139 seq,
4140 })
4141 .await?;
4142 self.state.intents_sent += 1;
4143 self.state.harvest_in_progress = true;
4144 self.state.harvest_started_at = Some(Instant::now());
4145 self.state.push_log("Harvesting…");
4146 crate::harvest_trace!(entity_id = self.state.entity_id, seq, "harvest intent queued to session");
4147 Ok(())
4148 }
4149
4150 pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
4151 if !self.state.is_alive() {
4152 anyhow::bail!("you are dead");
4153 }
4154 let blueprint_id = self
4155 .state
4156 .blueprints
4157 .iter()
4158 .find(|bp| self.state.can_craft_blueprint(bp))
4159 .map(|bp| bp.id.clone())
4160 .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
4161 self.craft(&blueprint_id, None).await
4162 }
4163
4164 pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
4165 if !self.state.is_alive() {
4166 anyhow::bail!("you are dead");
4167 }
4168 self.seq += 1;
4169 self.session
4170 .submit_intent(Intent::Craft {
4171 entity_id: self.state.entity_id,
4172 blueprint_id: blueprint_id.to_string(),
4173 count,
4174 seq: self.seq,
4175 })
4176 .await?;
4177 self.state.intents_sent += 1;
4178 let (label, batches) = self
4179 .state
4180 .blueprints
4181 .iter()
4182 .find(|b| b.id == blueprint_id)
4183 .map(|b| {
4184 let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
4185 (b.label.as_str(), n)
4186 })
4187 .unwrap_or((blueprint_id, count.unwrap_or(1)));
4188 self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
4189 Ok(())
4190 }
4191
4192 pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
4193 if !self.state.is_alive() {
4194 anyhow::bail!("you are dead");
4195 }
4196 let target_id = match self.state.nearest_interact_target() {
4197 Some(id) => id,
4198 None => {
4199 anyhow::bail!("nothing to interact with nearby");
4200 }
4201 };
4202 if self.state.npcs.iter().any(|n| n.id == target_id) {
4203 self.state.show_npc_verb_menu = true;
4204 self.state.npc_verb_target = Some(target_id);
4205 self.state.npc_verb_index = 0;
4206 return Ok(());
4207 }
4208 self.seq += 1;
4209 self.session
4210 .submit_intent(Intent::Interact {
4211 entity_id: self.state.entity_id,
4212 target_id: target_id.clone(),
4213 seq: self.seq,
4214 })
4215 .await?;
4216 self.state.intents_sent += 1;
4217 Ok(())
4218 }
4219
4220 pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
4222 if !self.state.is_alive() {
4223 anyhow::bail!("you are dead");
4224 }
4225 if self.state.nearest_interact_target().is_some() {
4226 return self.interact_nearest().await;
4227 }
4228 if let Some((label, dist)) = self.state.nearest_quest_board() {
4231 if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
4232 anyhow::bail!(
4233 "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
4234 );
4235 }
4236 }
4237 let (px, py) = self.state.player_position();
4238 let has_loot = self
4239 .state
4240 .ground_drops
4241 .iter()
4242 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
4243 if has_loot {
4244 return self.pickup_nearest().await;
4245 }
4246 if self
4247 .state
4248 .placed_containers
4249 .iter()
4250 .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
4251 {
4252 return self.pickup_nearest_container().await;
4253 }
4254 match self.harvest_nearest().await {
4255 Ok(()) => Ok(()),
4256 Err(err) => {
4257 let msg = err.to_string();
4258 if msg.contains("no harvestable") || msg.contains("press p") || msg.contains("press f") {
4259 anyhow::bail!(
4260 "nothing to use nearby — stand by an NPC/door, loot (*), chest, or resource"
4261 );
4262 }
4263 Err(err)
4264 }
4265 }
4266 }
4267
4268 pub async fn cast_hotbar_ability(&mut self, ability_id: &str) -> anyhow::Result<()> {
4270 if !self.state.is_alive() {
4271 anyhow::bail!("you are dead");
4272 }
4273 let target = if ability_id == "heal_touch" {
4274 Some(
4275 self.state
4276 .target_for_slot(2)
4277 .unwrap_or(self.state.entity_id),
4278 )
4279 } else {
4280 self.state
4281 .target_for_slot(1)
4282 .or_else(|| self.state.target_for_slot(2))
4283 };
4284 let Some(target_id) = target else {
4285 anyhow::bail!("no target — Tab to select, then press the hotbar key");
4286 };
4287 self.cast_ability(ability_id, Some(target_id)).await
4288 }
4289
4290 pub fn npc_verb_options(&self) -> Vec<&'static str> {
4291 self.state.npc_verb_options()
4292 }
4293
4294 pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
4295 let Some(npc_id) = self.state.npc_verb_target.clone() else {
4296 return Ok(());
4297 };
4298 let options = self.npc_verb_options();
4299 let choice = options
4300 .get(self.state.npc_verb_index)
4301 .copied()
4302 .unwrap_or("Talk");
4303 self.state.show_npc_verb_menu = false;
4304 self.state.npc_verb_target = None;
4305 self.seq += 1;
4306 match choice {
4307 "Trade" => {
4308 self.session
4309 .submit_intent(Intent::Interact {
4310 entity_id: self.state.entity_id,
4311 target_id: npc_id,
4312 seq: self.seq,
4313 })
4314 .await?;
4315 }
4316 _ => {
4317 self.session
4318 .submit_intent(Intent::NpcTalkOpen {
4319 entity_id: self.state.entity_id,
4320 npc_id,
4321 seq: self.seq,
4322 })
4323 .await?;
4324 }
4325 }
4326 self.state.intents_sent += 1;
4327 Ok(())
4328 }
4329
4330 pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
4331 let Some(chat) = self.state.npc_chat.clone() else {
4332 return Ok(());
4333 };
4334 let message = chat.input.trim().to_string();
4335 if message.is_empty() || chat.pending {
4336 return Ok(());
4337 }
4338 if let Some(c) = self.state.npc_chat.as_mut() {
4339 c.lines.push(format!("You: {message}"));
4340 c.input.clear();
4341 c.pending = true;
4342 }
4343 self.seq += 1;
4344 self.session
4345 .submit_intent(Intent::NpcTalkSay {
4346 entity_id: self.state.entity_id,
4347 npc_id: chat.npc_id,
4348 message,
4349 seq: self.seq,
4350 })
4351 .await?;
4352 self.state.intents_sent += 1;
4353 Ok(())
4354 }
4355
4356 pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
4357 let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
4358 self.state.show_npc_chat = false;
4359 return Ok(());
4360 };
4361 self.seq += 1;
4362 self.session
4363 .submit_intent(Intent::NpcTalkClose {
4364 entity_id: self.state.entity_id,
4365 npc_id,
4366 seq: self.seq,
4367 })
4368 .await?;
4369 self.state.intents_sent += 1;
4370 self.state.show_npc_chat = false;
4371 self.state.npc_chat = None;
4372 Ok(())
4373 }
4374
4375 pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
4376 self.seq += 1;
4377 self.session
4378 .submit_intent(Intent::TestDamage {
4379 entity_id: self.state.entity_id,
4380 amount,
4381 seq: self.seq,
4382 })
4383 .await?;
4384 self.state.intents_sent += 1;
4385 Ok(())
4386 }
4387
4388 pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
4389 self.cycle_combat_target_slot(1, reverse).await
4390 }
4391
4392 pub async fn cycle_combat_target_slot(
4393 &mut self,
4394 slot_index: u8,
4395 reverse: bool,
4396 ) -> anyhow::Result<()> {
4397 if !self.state.is_alive() {
4398 anyhow::bail!("you are dead");
4399 }
4400 let candidates = self.state.candidates_for_slot(slot_index);
4401 if candidates.is_empty() {
4402 anyhow::bail!("no targets nearby");
4403 }
4404 let current = self.state.target_for_slot(slot_index);
4405 let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
4406 let next_idx = match idx {
4407 None => 0,
4408 Some(i) if reverse => {
4409 if i == 0 {
4410 candidates.len() - 1
4411 } else {
4412 i - 1
4413 }
4414 }
4415 Some(i) => (i + 1) % candidates.len(),
4416 };
4417 if idx == Some(next_idx) && candidates.len() == 1 {
4418 self.clear_combat_target_slot(slot_index).await?;
4419 return Ok(());
4420 }
4421 let (target_id, label) = candidates[next_idx].clone();
4422 self.set_combat_target_slot(slot_index, target_id, &label)
4423 .await
4424 }
4425
4426 pub async fn set_combat_target_slot(
4427 &mut self,
4428 slot_index: u8,
4429 target_id: EntityId,
4430 label: &str,
4431 ) -> anyhow::Result<()> {
4432 if !self.state.is_alive() {
4433 anyhow::bail!("you are dead");
4434 }
4435 self.seq += 1;
4436 self.session
4437 .submit_intent(Intent::SetTargetSlot {
4438 entity_id: self.state.entity_id,
4439 slot_index,
4440 target_id,
4441 seq: self.seq,
4442 })
4443 .await?;
4444 self.state.intents_sent += 1;
4445 if slot_index == 1 {
4446 self.state.combat_target = Some(target_id);
4447 self.state.combat_target_label = Some(label.to_string());
4448 }
4449 self.state
4450 .push_log(format!("Slot {slot_index} target: {label}"));
4451 Ok(())
4452 }
4453
4454 pub async fn set_combat_target(
4455 &mut self,
4456 target_id: EntityId,
4457 label: &str,
4458 ) -> anyhow::Result<()> {
4459 self.set_combat_target_slot(1, target_id, label).await
4460 }
4461
4462 pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
4463 if slot_index == 1 && self.state.combat_target.is_none() {
4464 return Ok(());
4465 }
4466 self.seq += 1;
4467 self.session
4468 .submit_intent(Intent::ClearTargetSlot {
4469 entity_id: self.state.entity_id,
4470 slot_index,
4471 seq: self.seq,
4472 })
4473 .await?;
4474 if slot_index == 1 {
4475 self.state.combat_target = None;
4476 self.state.combat_target_label = None;
4477 }
4478 self.state.intents_sent += 1;
4479 self.state
4480 .push_log(format!("Slot {slot_index} target cleared"));
4481 Ok(())
4482 }
4483
4484 pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
4485 self.clear_combat_target_slot(1).await
4486 }
4487
4488 pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
4489 if !self.state.is_alive() {
4490 anyhow::bail!("you are dead");
4491 }
4492 self.seq += 1;
4493 self.session
4494 .submit_intent(Intent::AdvanceRotation {
4495 entity_id: self.state.entity_id,
4496 slot_index,
4497 seq: self.seq,
4498 })
4499 .await?;
4500 self.state.intents_sent += 1;
4501 Ok(())
4502 }
4503
4504 pub async fn assign_slot_preset(&mut self, slot_index: u8, preset_id: &str) -> anyhow::Result<()> {
4505 if !self.state.is_alive() {
4506 anyhow::bail!("you are dead");
4507 }
4508 self.seq += 1;
4509 self.session
4510 .submit_intent(Intent::AssignSlotPreset {
4511 entity_id: self.state.entity_id,
4512 slot_index,
4513 preset_id: preset_id.to_string(),
4514 seq: self.seq,
4515 })
4516 .await?;
4517 self.state.intents_sent += 1;
4518 if let Some(slot) = self
4519 .state
4520 .combat_slots
4521 .iter_mut()
4522 .find(|s| s.slot_index == slot_index)
4523 {
4524 slot.preset_id = Some(preset_id.to_string());
4525 if let Some(preset) = self.state.rotation_presets.iter().find(|p| p.id == preset_id) {
4526 slot.preset_label = Some(preset.label.clone());
4527 slot.rotation = preset.abilities.clone();
4528 slot.rotation_index = 0;
4529 }
4530 }
4531 self.state
4532 .push_log(format!("T{slot_index} loadout → {preset_id}"));
4533 Ok(())
4534 }
4535
4536 pub async fn cast_ability(
4537 &mut self,
4538 ability_id: &str,
4539 target_id: Option<EntityId>,
4540 ) -> anyhow::Result<()> {
4541 if !self.state.is_alive() {
4542 anyhow::bail!("you are dead");
4543 }
4544 let target_id = target_id
4545 .or_else(|| self.state.target_for_slot(2))
4546 .or_else(|| self.state.target_for_slot(1))
4547 .unwrap_or(self.state.entity_id);
4548 self.seq += 1;
4549 self.session
4550 .submit_intent(Intent::Cast {
4551 entity_id: self.state.entity_id,
4552 ability_id: ability_id.to_string(),
4553 target_id,
4554 seq: self.seq,
4555 })
4556 .await?;
4557 self.state.intents_sent += 1;
4558 self.state
4559 .push_log(format!("Cast {ability_id} → {target_id}"));
4560 Ok(())
4561 }
4562
4563 pub async fn upsert_rotation_preset(
4564 &mut self,
4565 preset: RotationPreset,
4566 ) -> anyhow::Result<()> {
4567 self.seq += 1;
4568 self.session
4569 .submit_intent(Intent::UpsertRotationPreset {
4570 entity_id: self.state.entity_id,
4571 preset: preset.clone(),
4572 seq: self.seq,
4573 })
4574 .await?;
4575 self.state.intents_sent += 1;
4576 if let Some(existing) = self
4577 .state
4578 .rotation_presets
4579 .iter_mut()
4580 .find(|p| p.id == preset.id)
4581 {
4582 *existing = preset.clone();
4583 } else {
4584 self.state.rotation_presets.push(preset.clone());
4585 }
4586 for slot in &mut self.state.combat_slots {
4587 if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
4588 slot.preset_label = Some(preset.label.clone());
4589 slot.rotation = preset.abilities.clone();
4590 }
4591 }
4592 self.state
4593 .push_log(format!("Saved rotation: {}", preset.label));
4594 Ok(())
4595 }
4596
4597 pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
4598 self.seq += 1;
4599 self.session
4600 .submit_intent(Intent::DeleteRotationPreset {
4601 entity_id: self.state.entity_id,
4602 preset_id: preset_id.to_string(),
4603 seq: self.seq,
4604 })
4605 .await?;
4606 self.state.intents_sent += 1;
4607 self.state
4608 .rotation_presets
4609 .retain(|p| p.id != preset_id);
4610 for slot in &mut self.state.combat_slots {
4611 if slot.preset_id.as_deref() == Some(preset_id) {
4612 slot.preset_id = None;
4613 slot.preset_label = None;
4614 slot.rotation.clear();
4615 slot.rotation_index = 0;
4616 }
4617 }
4618 self.state
4619 .push_log(format!("Deleted rotation: {preset_id}"));
4620 Ok(())
4621 }
4622
4623 pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
4624 if !self.state.is_alive() {
4625 anyhow::bail!("you are dead");
4626 }
4627 let enabled = !self
4628 .state
4629 .combat_slots
4630 .iter()
4631 .find(|s| s.slot_index == slot_index)
4632 .map(|s| s.auto_enabled)
4633 .unwrap_or(false);
4634 self.seq += 1;
4635 self.session
4636 .submit_intent(Intent::SetAutoAttack {
4637 entity_id: self.state.entity_id,
4638 slot_index,
4639 enabled,
4640 seq: self.seq,
4641 })
4642 .await?;
4643 if slot_index == 1 {
4644 self.state.auto_attack = enabled;
4645 }
4646 self.state.intents_sent += 1;
4647 self.state.push_log(format!(
4648 "T{slot_index} auto {}",
4649 if enabled { "ON" } else { "OFF" }
4650 ));
4651 Ok(())
4652 }
4653
4654 pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
4655 if !self.state.connected {
4656 anyhow::bail!("not connected");
4657 }
4658 if !self.state.is_alive() {
4659 anyhow::bail!("you are dead");
4660 }
4661 let (px, py) = self.state.player_position();
4662 if self
4663 .state
4664 .ground_drops
4665 .iter()
4666 .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
4667 {
4668 anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
4669 }
4670 self.seq += 1;
4671 self.session
4672 .submit_intent(Intent::Pickup {
4673 entity_id: self.state.entity_id,
4674 drop_id: None,
4675 seq: self.seq,
4676 })
4677 .await?;
4678 self.state.intents_sent += 1;
4679 Ok(())
4680 }
4681
4682 pub async fn toggle_auto_attack(&mut self) -> anyhow::Result<()> {
4683 self.toggle_auto_attack_slot(1).await
4684 }
4685
4686 pub async fn dodge(&mut self) -> 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::Dodge {
4693 entity_id: self.state.entity_id,
4694 seq: self.seq,
4695 })
4696 .await?;
4697 self.state.intents_sent += 1;
4698 self.state.push_log("Dodge!");
4699 Ok(())
4700 }
4701
4702 pub async fn lunge(&mut self) -> anyhow::Result<()> {
4703 if !self.state.is_alive() {
4704 anyhow::bail!("you are dead");
4705 }
4706 let (forward, strafe) = self.last_move_axes();
4707 self.seq += 1;
4708 self.session
4709 .submit_intent(Intent::Lunge {
4710 entity_id: self.state.entity_id,
4711 forward,
4712 strafe,
4713 seq: self.seq,
4714 })
4715 .await?;
4716 self.state.intents_sent += 1;
4717 self.state.push_log("Lunge!");
4718 Ok(())
4719 }
4720
4721 pub fn last_move_axes(&self) -> (f32, f32) {
4723 (self.last_move_forward, self.last_move_strafe)
4724 }
4725
4726 pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
4727 if !self.state.is_alive() {
4728 anyhow::bail!("you are dead");
4729 }
4730 self.seq += 1;
4731 self.session
4732 .submit_intent(Intent::Block {
4733 entity_id: self.state.entity_id,
4734 enabled,
4735 seq: self.seq,
4736 })
4737 .await?;
4738 self.state.intents_sent += 1;
4739 if enabled {
4740 self.state.push_log("Blocking");
4741 }
4742 Ok(())
4743 }
4744
4745 pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
4746 if !self.state.is_alive() {
4747 anyhow::bail!("you are dead");
4748 }
4749 self.seq += 1;
4750 self.session
4751 .submit_intent(Intent::EquipMainhand {
4752 entity_id: self.state.entity_id,
4753 template_id,
4754 seq: self.seq,
4755 })
4756 .await?;
4757 self.state.intents_sent += 1;
4758 Ok(())
4759 }
4760
4761 pub async fn say(&mut self, channel: flatland_protocol::ChatChannel, text: &str) -> anyhow::Result<()> {
4762 self.seq += 1;
4763 self.session
4764 .submit_intent(Intent::Say {
4765 entity_id: self.state.entity_id,
4766 channel,
4767 text: text.to_string(),
4768 seq: self.seq,
4769 })
4770 .await?;
4771 self.state.intents_sent += 1;
4772 Ok(())
4773 }
4774
4775 pub async fn stop(&mut self) -> anyhow::Result<()> {
4776 self.seq += 1;
4777 self.session
4778 .submit_intent(Intent::Stop {
4779 entity_id: self.state.entity_id,
4780 seq: self.seq,
4781 })
4782 .await?;
4783 self.state.intents_sent += 1;
4784 Ok(())
4785 }
4786
4787 pub fn disconnect(&self) {
4788 self.session.disconnect();
4789 }
4790}
4791
4792fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
4793 let dx = ax - bx;
4794 let dy = ay - by;
4795 (dx * dx + dy * dy).sqrt()
4796}
4797
4798#[cfg(test)]
4799mod tests {
4800 use std::collections::BTreeMap;
4801
4802 use super::*;
4803 use flatland_protocol::{
4804 BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
4805 };
4806
4807 fn sample_state() -> GameState {
4808 let mut state = GameState {
4809 session_id: 1,
4810 entity_id: 1,
4811 character_id: None,
4812 tick: 0,
4813 chunk_rev: 0,
4814 content_rev: 0,
4815 entities: vec![EntityState {
4816 id: 1,
4817 label: "You".into(),
4818 transform: Transform {
4819 position: WorldCoord::surface(128.0, 128.0),
4820 yaw: 0.0,
4821 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
4822 },
4823 vitals: None,
4824 attributes: None,
4825 skills: None,
4826 inside_building: None,
4827 tile_id: None,
4828 presentation_state: None,
4829 sprite_mode: None,
4830 }],
4831 player: None,
4832 resource_nodes: vec![ResourceNodeView {
4833 id: "oak-1".into(),
4834 label: "Oak".into(),
4835 x: 130.0,
4836 y: 128.0,
4837 z: 0.0,
4838 item_template: "oak_log".into(),
4839 state: ResourceNodeState::Available,
4840 blocking: true,
4841 blocking_radius_m: 0.8,
4842 tile_id: None,
4843 sprite_mode: None,
4844 presentation_state: None,
4845 }],
4846 ground_drops: vec![],
4847 placed_containers: vec![],
4848 buildings: vec![BuildingView {
4849 id: "broker-hut".into(),
4850 label: "Broker".into(),
4851 x: 148.0,
4852 y: 118.0,
4853 width_m: 8.0,
4854 depth_m: 6.0,
4855 interior_blueprint: Some("broker_hut".into()),
4856 tags: vec![],
4857 }],
4858 doors: vec![flatland_protocol::DoorView {
4859 id: "door-1".into(),
4860 building_id: "broker-hut".into(),
4861 x: 148.0,
4862 y: 118.0,
4863 open: false,
4864 portal: Some("front".into()),
4865 }],
4866 interior_map: None,
4867 npcs: vec![],
4868 blueprints: vec![],
4869 world_width_m: 256.0,
4870 world_height_m: 256.0,
4871 terrain_zones: Vec::new(),
4872 z_platforms: Vec::new(),
4873 z_transitions: Vec::new(),
4874 world_clock: flatland_protocol::WorldClock::default(),
4875 inventory: std::collections::HashMap::new(),
4876 inventory_hints: std::collections::HashMap::new(),
4877 logs: VecDeque::new(),
4878 intents_sent: 0,
4879 ticks_received: 0,
4880 connected: true,
4881 disconnect_reason: None,
4882 show_stats: false,
4883 show_craft_menu: false,
4884 craft_menu_index: 0,
4885 craft_batch_quantity: 1,
4886 show_shop_menu: false,
4887 shop_catalog: None,
4888 shop_tab: ShopTab::default(),
4889 shop_menu_index: 0,
4890 shop_quantity: 1,
4891 shop_trade_log: VecDeque::new(),
4892 show_npc_verb_menu: false,
4893 npc_verb_target: None,
4894 npc_verb_index: 0,
4895 show_npc_chat: false,
4896 npc_chat: None,
4897 show_inventory_menu: false,
4898 inventory_menu_index: 0,
4899 show_move_picker: false,
4900 show_rename_prompt: false,
4901 rename_buffer: String::new(),
4902 move_picker_index: 0,
4903 move_picker: None,
4904 show_destroy_picker: false,
4905 destroy_confirm_pending: false,
4906 destroy_picker: None,
4907 combat_target: None,
4908 combat_target_label: None,
4909 in_combat: false,
4910 auto_attack: true,
4911 combat_has_los: false,
4912 attack_cd_ticks: 0,
4913 gcd_ticks: 0,
4914 weapon_ability_id: "unarmed".into(),
4915 mainhand_template_id: None,
4916 mainhand_label: None,
4917 worn: BTreeMap::new(),
4918 carry_mass: 0.0,
4919 carry_mass_max: 0.0,
4920 encumbrance: flatland_protocol::EncumbranceState::Light,
4921 inventory_stacks: Vec::new(),
4922 keychain_stacks: Vec::new(),
4923 combat_target_detail: None,
4924 cast_progress: None,
4925 ability_cooldowns: Vec::new(),
4926 blocking_active: false,
4927 max_target_slots: 1,
4928 combat_slots: Vec::new(),
4929 rotation_presets: Vec::new(),
4930 show_loadout_menu: false,
4931 show_keychain_menu: false,
4932 keychain_menu_index: 0,
4933 show_rotation_editor: false,
4934 loadout_menu_index: 0,
4935 rotation_editor: RotationEditorState::default(),
4936 harvest_in_progress: false,
4937 harvest_started_at: None,
4938 pending_craft_ack: None,
4939 quest_log: Vec::new(),
4940 interactables: Vec::new(),
4941 show_quest_offer: false,
4942 pending_quest_offer: None,
4943 show_quest_menu: false,
4944 quest_menu_index: 0,
4945 quest_withdraw_confirm: false,
4946 };
4947 state.player = state.entities.first().cloned();
4948 state
4949 }
4950
4951 #[test]
4952 fn probe_use_world_npc_beats_nearby_loot() {
4953 let mut state = sample_state();
4954 state.npcs.push(flatland_protocol::NpcView {
4955 id: "ada".into(),
4956 label: "Ada".into(),
4957 role: "broker".into(),
4958 x: 129.0,
4959 y: 128.0,
4960 building_id: None,
4961 entity_id: None,
4962 life_state: None,
4963 hp_pct: None,
4964 can_trade: true,
4965 tile_id: None,
4966 behavior_state: None,
4967 presentation_state: None,
4968 sprite_mode: None,
4969 });
4970 state.ground_drops.push(flatland_protocol::GroundDropView {
4971 id: "d1".into(),
4972 template_id: "lumber".into(),
4973 quantity: 1,
4974 x: 128.5,
4975 y: 128.0,
4976 z: 0.0,
4977 tile_id: None,
4978 });
4979 let probe = state.probe_use_world();
4980 let primary = probe.primary.expect("primary");
4981 assert_eq!(primary.kind, crate::UseWorldKind::Npc);
4982 assert_eq!(primary.id, "ada");
4983 }
4984
4985 #[test]
4986 fn probe_use_world_harvest_when_in_range() {
4987 let state = sample_state(); let probe = state.probe_use_world();
4989 assert!(probe.primary.is_none(), "oak is 2m away, out of harvest range");
4990 assert!(probe.candidates.iter().any(|c| c.kind == crate::UseWorldKind::Harvest));
4991
4992 let mut state = sample_state();
4993 state.resource_nodes[0].x = 129.0;
4994 let probe = state.probe_use_world();
4995 let primary = probe.primary.expect("primary");
4996 assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
4997 }
4998
4999 #[test]
5000 fn empty_entity_tick_preserves_welcome_snapshot() {
5001 let mut state = sample_state();
5002 state.inventory.insert("carrot".into(), 3);
5003 let delta = TickDelta {
5004 tick: 1,
5005 entities: vec![],
5006 resource_nodes: vec![],
5007 ground_drops: vec![],
5008 placed_containers: vec![],
5009 buildings: vec![],
5010 doors: vec![],
5011 interior_map: None,
5012 npcs: vec![],
5013 inventory: vec![],
5014 blueprints: vec![],
5015 world_clock: flatland_protocol::WorldClock::default(),
5016 combat: None,
5017 quest_log: vec![],
5018 interactables: vec![],
5019 };
5020
5021 state.apply_tick_fields(&delta, 1);
5022
5023 assert_eq!(state.entities.len(), 1);
5024 assert!(state.player.is_some());
5025 assert_eq!(state.inventory.get("carrot"), Some(&3));
5026 assert_eq!(state.resource_nodes.len(), 1);
5027 }
5028
5029 #[test]
5030 fn tick_preserves_world_layers_when_delta_omits_them() {
5031 let mut state = sample_state();
5032 let delta = TickDelta {
5033 tick: 1,
5034 entities: state.entities.clone(),
5035 resource_nodes: vec![],
5036 ground_drops: vec![],
5037 placed_containers: vec![],
5038 buildings: vec![],
5039 doors: vec![],
5040 interior_map: None,
5041 npcs: vec![],
5042 inventory: vec![],
5043 blueprints: vec![],
5044 world_clock: flatland_protocol::WorldClock::default(),
5045 combat: None,
5046 quest_log: vec![],
5047 interactables: vec![],
5048 };
5049
5050 state.apply_tick_fields(&delta, 1);
5051
5052 assert_eq!(state.resource_nodes.len(), 1);
5053 assert_eq!(state.buildings.len(), 1);
5054 assert_eq!(state.doors.len(), 1);
5055 }
5056
5057 #[test]
5058 fn tick_updates_resource_nodes_when_server_sends_them() {
5059 let mut state = sample_state();
5060 let delta = TickDelta {
5061 tick: 1,
5062 entities: state.entities.clone(),
5063 resource_nodes: vec![ResourceNodeView {
5064 id: "oak-1".into(),
5065 label: "Oak".into(),
5066 x: 130.0,
5067 y: 128.0,
5068 z: 0.0,
5069 item_template: "oak_log".into(),
5070 state: ResourceNodeState::Cooldown,
5071 blocking: true,
5072 blocking_radius_m: 0.8,
5073 tile_id: None,
5074 sprite_mode: None,
5075 presentation_state: None,
5076 }],
5077 buildings: vec![],
5078 doors: vec![],
5079 interior_map: None,
5080 npcs: vec![],
5081 inventory: vec![],
5082 blueprints: vec![],
5083 world_clock: flatland_protocol::WorldClock::default(),
5084 ground_drops: vec![],
5085 placed_containers: vec![],
5086 combat: None,
5087 quest_log: vec![],
5088 interactables: vec![],
5089 };
5090
5091 state.apply_tick_fields(&delta, 1);
5092
5093 assert!(matches!(
5094 state.resource_nodes[0].state,
5095 ResourceNodeState::Cooldown
5096 ));
5097 }
5098
5099 #[test]
5100 fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
5101 let mut state = GameState {
5102 session_id: 1,
5103 entity_id: 1,
5104 character_id: None,
5105 tick: 0,
5106 chunk_rev: 0,
5107 content_rev: 0,
5108 entities: vec![EntityState {
5109 id: 1,
5110 label: "You".into(),
5111 transform: Transform {
5112 position: WorldCoord::surface(4.5, 2.0),
5113 yaw: 0.0,
5114 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
5115 },
5116 vitals: None,
5117 attributes: None,
5118 skills: None,
5119 inside_building: Some("broker_hut".into()),
5120 tile_id: None,
5121 presentation_state: None,
5122 sprite_mode: None,
5123 }],
5124 player: None,
5125 resource_nodes: vec![],
5126 ground_drops: vec![],
5127 placed_containers: vec![],
5128 buildings: vec![BuildingView {
5129 id: "broker_hut".into(),
5130 label: "Broker".into(),
5131 x: 158.0,
5132 y: 124.0,
5133 width_m: 8.0,
5134 depth_m: 6.0,
5135 interior_blueprint: Some("broker_hut".into()),
5136 tags: vec![],
5137 }],
5138 doors: vec![flatland_protocol::DoorView {
5139 id: "broker_hut_exit".into(),
5140 building_id: "broker_hut".into(),
5141 x: 4.3,
5142 y: 0.9,
5143 open: true,
5144 portal: Some("front".into()),
5145 }],
5146 interior_map: None,
5147 npcs: vec![flatland_protocol::NpcView {
5148 id: "ada_broker".into(),
5149 label: "Ada".into(),
5150 x: 4.5,
5151 y: 2.0,
5152 building_id: Some("broker_hut".into()),
5153 role: "broker".into(),
5154 entity_id: None,
5155 life_state: None,
5156 hp_pct: None,
5157 can_trade: true,
5158 tile_id: None,
5159 behavior_state: None,
5160 presentation_state: None,
5161 sprite_mode: None,
5162 }],
5163 blueprints: vec![],
5164 world_width_m: 256.0,
5165 world_height_m: 256.0,
5166 terrain_zones: Vec::new(),
5167 z_platforms: Vec::new(),
5168 z_transitions: Vec::new(),
5169 world_clock: flatland_protocol::WorldClock::default(),
5170 inventory: std::collections::HashMap::new(),
5171 inventory_hints: std::collections::HashMap::new(),
5172 logs: VecDeque::new(),
5173 intents_sent: 0,
5174 ticks_received: 0,
5175 connected: true,
5176 disconnect_reason: None,
5177 show_stats: false,
5178 show_craft_menu: false,
5179 craft_menu_index: 0,
5180 craft_batch_quantity: 1,
5181 show_shop_menu: false,
5182 shop_catalog: None,
5183 shop_tab: ShopTab::default(),
5184 shop_menu_index: 0,
5185 shop_quantity: 1,
5186 shop_trade_log: VecDeque::new(),
5187 show_npc_verb_menu: false,
5188 npc_verb_target: None,
5189 npc_verb_index: 0,
5190 show_npc_chat: false,
5191 npc_chat: None,
5192 show_inventory_menu: false,
5193 inventory_menu_index: 0,
5194 show_move_picker: false,
5195 show_rename_prompt: false,
5196 rename_buffer: String::new(),
5197 move_picker_index: 0,
5198 move_picker: None,
5199 show_destroy_picker: false,
5200 destroy_confirm_pending: false,
5201 destroy_picker: None,
5202 combat_target: None,
5203 combat_target_label: None,
5204 in_combat: false,
5205 auto_attack: true,
5206 combat_has_los: false,
5207 attack_cd_ticks: 0,
5208 gcd_ticks: 0,
5209 weapon_ability_id: "unarmed".into(),
5210 mainhand_template_id: None,
5211 mainhand_label: None,
5212 worn: BTreeMap::new(),
5213 carry_mass: 0.0,
5214 carry_mass_max: 0.0,
5215 encumbrance: flatland_protocol::EncumbranceState::Light,
5216 inventory_stacks: Vec::new(),
5217 keychain_stacks: Vec::new(),
5218 combat_target_detail: None,
5219 cast_progress: None,
5220 ability_cooldowns: Vec::new(),
5221 blocking_active: false,
5222 max_target_slots: 1,
5223 combat_slots: Vec::new(),
5224 rotation_presets: Vec::new(),
5225 show_loadout_menu: false,
5226 show_keychain_menu: false,
5227 keychain_menu_index: 0,
5228 show_rotation_editor: false,
5229 loadout_menu_index: 0,
5230 rotation_editor: RotationEditorState::default(),
5231 harvest_in_progress: false,
5232 harvest_started_at: None,
5233 pending_craft_ack: None,
5234 quest_log: Vec::new(),
5235 interactables: Vec::new(),
5236 show_quest_offer: false,
5237 pending_quest_offer: None,
5238 show_quest_menu: false,
5239 quest_menu_index: 0,
5240 quest_withdraw_confirm: false,
5241 };
5242 state.player = state.entities.first().cloned();
5243 assert_eq!(
5244 state.nearest_interact_target().as_deref(),
5245 Some("ada_broker")
5246 );
5247 }
5248
5249 #[test]
5250 fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
5251 let mut state = sample_state();
5252 state.placed_containers = vec![
5255 flatland_protocol::PlacedContainerView {
5256 id: "near".into(),
5257 template_id: "wooden_chest_small".into(),
5258 display_name: "Wooden Chest".into(),
5259 x: 130.0,
5260 y: 128.0,
5261 z: 0.0,
5262 locked: true,
5263 accessible: true,
5264 owner_character_id: None,
5265 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
5266 lock_id: None,
5267 capacity_volume: None,
5268 item_instance_id: Some(uuid::Uuid::from_u128(1)),
5269 tile_id: None,
5270 },
5271 flatland_protocol::PlacedContainerView {
5272 id: "far".into(),
5273 template_id: "wooden_chest_small".into(),
5274 display_name: "Distant Chest".into(),
5275 x: 128.0 + CONTAINER_RANGE_M + 5.0,
5276 y: 128.0,
5277 z: 0.0,
5278 locked: false,
5279 accessible: true,
5280 owner_character_id: None,
5281 contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
5282 lock_id: None,
5283 capacity_volume: None,
5284 item_instance_id: Some(uuid::Uuid::from_u128(2)),
5285 tile_id: None,
5286 },
5287 ];
5288
5289 let nearby = state.nearby_containers();
5290 assert_eq!(nearby.len(), 1, "far chest must not appear once out of range");
5291 assert_eq!(nearby[0].view.id, "near");
5292 assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
5293 assert!(nearby[0].rows[0].is_chest_shell);
5294
5295 state.placed_containers[0].accessible = false;
5298 let nearby = state.nearby_containers();
5299 assert_eq!(nearby.len(), 1);
5300 assert_eq!(nearby[0].rows.len(), 1);
5301 assert!(nearby[0].rows[0].is_chest_shell);
5302 }
5303
5304 #[test]
5305 fn placed_container_public_label_hides_owner_custom_name() {
5306 let owner = uuid::Uuid::from_u128(99);
5307 let mut state = sample_state();
5308 state.character_id = Some(uuid::Uuid::from_u128(1));
5309 state.inventory_hints.insert(
5310 "wooden_chest_medium".into(),
5311 InventoryHint {
5312 display_name: "Medium Wooden Chest".into(),
5313 category: "container".into(),
5314 base_mass: None,
5315 base_volume: None,
5316 capacity_volume: None,
5317 stackable: false,
5318 },
5319 );
5320 let chest = flatland_protocol::PlacedContainerView {
5321 id: "c1".into(),
5322 template_id: "wooden_chest_medium".into(),
5323 display_name: "Barry's Loot #a3f2".into(),
5324 x: 128.0,
5325 y: 128.0,
5326 z: 0.0,
5327 locked: false,
5328 accessible: true,
5329 owner_character_id: Some(owner),
5330 contents: vec![],
5331 lock_id: None,
5332 capacity_volume: None,
5333 item_instance_id: None,
5334 tile_id: None,
5335 };
5336 assert_eq!(
5337 state.placed_container_public_label(&chest),
5338 "Medium Wooden Chest"
5339 );
5340 state.character_id = Some(owner);
5341 assert_eq!(
5342 state.placed_container_public_label(&chest),
5343 "Barry's Loot #a3f2"
5344 );
5345 }
5346
5347 #[test]
5348 fn location_context_lists_nearby_resource_node() {
5349 let mut state = sample_state();
5350 state.player = state.entities.first().cloned();
5351 state.resource_nodes[0].x = 128.2;
5352 state.resource_nodes[0].y = 128.0;
5353 let lines = state.location_context_lines();
5354 assert!(
5355 lines.iter().any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
5356 "expected resource node in context: {:?}",
5357 lines
5358 );
5359 }
5360
5361 #[test]
5362 fn quest_board_usable_within_board_radius() {
5363 let mut state = sample_state();
5364 state.player = state.entities.first().cloned();
5365 state.interactables = vec![flatland_protocol::InteractableView {
5366 id: "board-1".into(),
5367 kind: "quest_board".into(),
5368 label: "Town Quest Board".into(),
5369 x: 130.5,
5370 y: 128.0,
5371 z: 0.0,
5372 board_id: Some("starter_town_board".into()),
5373 }];
5374 assert_eq!(
5376 state.nearest_interact_target().as_deref(),
5377 Some("board-1"),
5378 "quest board should be selectable at ~2.5m"
5379 );
5380 let lines = state.location_context_lines();
5381 assert!(
5382 lines
5383 .iter()
5384 .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
5385 "HUD should advertise f when board is in range: {:?}",
5386 lines
5387 );
5388 }
5389
5390 #[test]
5391 fn inventory_selectable_rows_orders_worn_before_person_before_nearby() {
5392 let mut state = sample_state();
5393 state.worn.insert(
5394 BodySlot::Back,
5395 flatland_protocol::ItemStack {
5396 template_id: "travel_backpack".into(),
5397 quantity: 1,
5398 item_instance_id: Some(uuid::Uuid::from_u128(3)),
5399 props: Default::default(),
5400 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
5401 display_name: None,
5402 category: None,
5403 base_mass: None,
5404 base_volume: None,
5405 capacity_volume: None,
5406 stackable: None,
5407 },
5408 );
5409 state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
5410 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
5411 id: "chest-1".into(),
5412 template_id: "wooden_chest_small".into(),
5413 display_name: "Wooden Chest".into(),
5414 x: 129.0,
5415 y: 128.0,
5416 z: 0.0,
5417 locked: false,
5418 accessible: true,
5419 owner_character_id: None,
5420 contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
5421 lock_id: None,
5422 capacity_volume: None,
5423 item_instance_id: Some(uuid::Uuid::from_u128(4)),
5424 tile_id: None,
5425 }];
5426
5427 let rows = state.inventory_selectable_rows();
5428 let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
5429 assert_eq!(
5430 sections,
5431 vec![
5432 InventorySection::Worn, InventorySection::Worn, InventorySection::Person, InventorySection::Nearby, InventorySection::Nearby, ]
5438 );
5439 assert_eq!(rows[0].stack.template_id, "travel_backpack");
5440 assert!(rows[0].is_equip_shell);
5441 assert_eq!(rows[1].stack.template_id, "iron_ore");
5442 assert_eq!(rows[1].depth, 1);
5443 assert_eq!(rows[2].stack.template_id, "lumber");
5444 assert!(rows[3].is_chest_shell);
5445 assert_eq!(rows[4].stack.template_id, "wood_axe");
5446 assert_eq!(rows[4].depth, 1);
5447
5448 let lines = state.inventory_browser_lines();
5449 assert!(lines.iter().any(|l| matches!(
5450 l,
5451 InventoryBrowserLine::Section(s) if s.contains("Worn")
5452 )));
5453 assert!(lines.iter().any(|l| matches!(
5454 l,
5455 InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
5456 || text.contains("backpack")
5457 )));
5458 }
5459
5460 #[test]
5461 fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
5462 let mut state = sample_state();
5463 let back_id = uuid::Uuid::from_u128(5);
5464 state.worn.insert(
5465 BodySlot::Back,
5466 flatland_protocol::ItemStack {
5467 template_id: "travel_backpack".into(),
5468 quantity: 1,
5469 item_instance_id: Some(back_id),
5470 props: Default::default(),
5471 contents: Vec::new(),
5472 display_name: None,
5473 category: Some("container".into()),
5474 base_mass: None,
5475 base_volume: None,
5476 capacity_volume: Some(80.0),
5477 stackable: None,
5478 },
5479 );
5480 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
5481 id: "chest-1".into(),
5482 template_id: "wooden_chest_small".into(),
5483 display_name: "Wooden Chest".into(),
5484 x: 129.0,
5485 y: 128.0,
5486 z: 0.0,
5487 locked: false,
5488 accessible: true,
5489 owner_character_id: None,
5490 contents: Vec::new(),
5491 lock_id: None,
5492 capacity_volume: None,
5493 item_instance_id: Some(uuid::Uuid::from_u128(6)),
5494 tile_id: None,
5495 }];
5496
5497 let opts = state.move_destinations_for(
5500 &flatland_protocol::InventoryLocation::Root,
5501 None,
5502 None,
5503 "lumber",
5504 );
5505 assert!(!opts.iter().any(|o| matches!(
5506 &o.kind,
5507 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
5508 )));
5509 assert!(opts.iter().any(|o| matches!(
5510 &o.kind,
5511 MoveOptionKind::Move { location, parent_instance_id, .. }
5512 if *location == flatland_protocol::InventoryLocation::Worn {
5513 slot: BodySlot::Back,
5514 } && *parent_instance_id == Some(back_id)
5515 )));
5516 assert!(opts.iter().any(|o| matches!(
5517 &o.kind,
5518 MoveOptionKind::Move { location, .. }
5519 if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
5520 )));
5521 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
5522 assert!(matches!(
5523 opts[opts.len() - 2].kind,
5524 MoveOptionKind::Drop
5525 ));
5526
5527 let from_backpack = flatland_protocol::InventoryLocation::Worn {
5531 slot: BodySlot::Back,
5532 };
5533 let opts = state.move_destinations_for(
5534 &from_backpack,
5535 Some(back_id),
5536 None,
5537 "iron_ore",
5538 );
5539 assert!(!opts.iter().any(|o| matches!(
5540 &o.kind,
5541 MoveOptionKind::Move { location, parent_instance_id, .. }
5542 if *location == from_backpack && *parent_instance_id == Some(back_id)
5543 )));
5544 assert!(opts.iter().any(|o| matches!(
5545 &o.kind,
5546 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
5547 )));
5548 }
5549
5550 #[test]
5551 fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
5552 let mut state = sample_state();
5553 state.worn.insert(
5556 BodySlot::Waist,
5557 flatland_protocol::ItemStack {
5558 template_id: "simple_belt".into(),
5559 quantity: 1,
5560 item_instance_id: Some(uuid::Uuid::from_u128(10)),
5561 props: Default::default(),
5562 contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
5563 display_name: None,
5564 category: Some("container".into()),
5565 base_mass: None,
5566 base_volume: None,
5567 capacity_volume: None,
5568 stackable: None,
5569 },
5570 );
5571 state.worn.insert(
5572 BodySlot::Head,
5573 flatland_protocol::ItemStack {
5574 template_id: "cloth_cap".into(),
5575 quantity: 1,
5576 item_instance_id: Some(uuid::Uuid::from_u128(11)),
5577 props: Default::default(),
5578 contents: Vec::new(),
5579 display_name: None,
5580 category: Some("armor".into()),
5581 base_mass: None,
5582 base_volume: None,
5583 capacity_volume: None,
5584 stackable: None,
5585 },
5586 );
5587 state.worn.insert(
5588 BodySlot::Back,
5589 flatland_protocol::ItemStack {
5590 template_id: "travel_backpack".into(),
5591 quantity: 1,
5592 item_instance_id: Some(uuid::Uuid::from_u128(12)),
5593 props: Default::default(),
5594 contents: Vec::new(),
5595 display_name: None,
5596 category: Some("container".into()),
5597 base_mass: None,
5598 base_volume: None,
5599 capacity_volume: None,
5600 stackable: None,
5601 },
5602 );
5603
5604 let rows = state.worn_rows();
5605 assert_eq!(rows.len(), 4);
5607 assert_eq!(rows[0].stack.template_id, "cloth_cap");
5608 assert!(rows[0].is_equip_shell);
5609 assert_eq!(rows[1].stack.template_id, "travel_backpack");
5610 assert!(rows[1].is_equip_shell);
5611 assert_eq!(rows[2].stack.template_id, "simple_belt");
5612 assert!(rows[2].is_equip_shell);
5613 assert_eq!(rows[3].stack.template_id, "leather_pouch");
5614 assert_eq!(rows[3].depth, 1);
5615 assert!(!rows[3].is_equip_shell);
5616 }
5617
5618 #[test]
5619 fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
5620 let mut state = sample_state();
5621 state.worn.insert(
5622 BodySlot::Waist,
5623 flatland_protocol::ItemStack {
5624 template_id: "simple_belt".into(),
5625 quantity: 1,
5626 item_instance_id: Some(uuid::Uuid::from_u128(20)),
5627 props: Default::default(),
5628 contents: Vec::new(),
5629 display_name: Some("Simple Belt".into()),
5630 category: Some("container".into()),
5631 base_mass: None,
5632 base_volume: None,
5633 capacity_volume: None,
5634 stackable: None,
5635 },
5636 );
5637 state.worn.insert(
5638 BodySlot::Head,
5639 flatland_protocol::ItemStack {
5640 template_id: "cloth_cap".into(),
5641 quantity: 1,
5642 item_instance_id: Some(uuid::Uuid::from_u128(21)),
5643 props: Default::default(),
5644 contents: Vec::new(),
5645 display_name: Some("Cloth Cap".into()),
5646 category: Some("armor".into()),
5647 base_mass: None,
5648 base_volume: None,
5649 capacity_volume: None,
5650 stackable: None,
5651 },
5652 );
5653
5654 let opts = state.move_destinations_for(
5655 &flatland_protocol::InventoryLocation::Root,
5656 None,
5657 None,
5658 "leather_pouch",
5659 );
5660 assert!(
5661 opts.iter().any(|o| matches!(
5662 &o.kind,
5663 MoveOptionKind::Move { location, .. }
5664 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
5665 )),
5666 "belt loop must be offered when moving a pouch"
5667 );
5668 assert!(
5669 !opts.iter().any(|o| matches!(
5670 &o.kind,
5671 MoveOptionKind::Move { location, .. }
5672 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
5673 )),
5674 "armor slots can't hold other items and must not appear as move destinations"
5675 );
5676 let belt_opt = opts
5677 .iter()
5678 .find(|o| matches!(
5679 &o.kind,
5680 MoveOptionKind::Move { location, .. }
5681 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
5682 ))
5683 .unwrap();
5684 assert!(belt_opt.label.contains("belt loop"));
5685
5686 let opts = state.move_destinations_for(
5687 &flatland_protocol::InventoryLocation::Root,
5688 None,
5689 None,
5690 "lumber",
5691 );
5692 assert!(
5693 !opts.iter().any(|o| o.label.contains("belt loop")),
5694 "loose materials must not target the belt shell — only nested pouches"
5695 );
5696 }
5697
5698 #[test]
5699 fn move_destinations_for_offers_dimensional_pouch_on_belt() {
5700 let mut state = sample_state();
5701 let belt_id = uuid::Uuid::from_u128(30);
5702 let pouch_id = uuid::Uuid::from_u128(31);
5703 state.worn.insert(
5704 BodySlot::Waist,
5705 flatland_protocol::ItemStack {
5706 template_id: "simple_belt".into(),
5707 quantity: 1,
5708 item_instance_id: Some(belt_id),
5709 props: Default::default(),
5710 contents: vec![flatland_protocol::ItemStack {
5711 template_id: "dimensional_pouch".into(),
5712 quantity: 1,
5713 item_instance_id: Some(pouch_id),
5714 props: Default::default(),
5715 contents: Vec::new(),
5716 display_name: Some("Dimensional Pouch".into()),
5717 category: Some("container".into()),
5718 base_mass: None,
5719 base_volume: None,
5720 capacity_volume: Some(200.0),
5721 stackable: None,
5722 }],
5723 display_name: Some("Simple Belt".into()),
5724 category: Some("container".into()),
5725 base_mass: None,
5726 base_volume: None,
5727 capacity_volume: None,
5728 stackable: None,
5729 },
5730 );
5731
5732 let opts = state.move_destinations_for(
5733 &flatland_protocol::InventoryLocation::Root,
5734 None,
5735 None,
5736 "iron_ore",
5737 );
5738 assert!(
5739 opts.iter().any(|o| matches!(
5740 &o.kind,
5741 MoveOptionKind::Move {
5742 location,
5743 parent_instance_id,
5744 ..
5745 } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
5746 && *parent_instance_id == Some(pouch_id)
5747 )),
5748 "dimensional pouch clipped on belt must accept loose items"
5749 );
5750 assert!(
5751 opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
5752 "destination label should name the pouch"
5753 );
5754 }
5755
5756 #[test]
5757 fn container_volume_label_on_placed_chest_shell() {
5758 let mut state = sample_state();
5759 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
5760 id: "chest-1".into(),
5761 template_id: "wooden_chest_small".into(),
5762 display_name: "Camp Chest".into(),
5763 x: 129.0,
5764 y: 128.0,
5765 z: 0.0,
5766 locked: false,
5767 accessible: true,
5768 owner_character_id: None,
5769 contents: vec![flatland_protocol::ItemStack {
5770 template_id: "iron_ore".into(),
5771 quantity: 2,
5772 item_instance_id: None,
5773 props: Default::default(),
5774 contents: Vec::new(),
5775 display_name: None,
5776 category: None,
5777 base_mass: None,
5778 base_volume: Some(2.0),
5779 capacity_volume: None,
5780 stackable: None,
5781 }],
5782 lock_id: None,
5783 capacity_volume: Some(60.0),
5784 item_instance_id: Some(uuid::Uuid::from_u128(4)),
5785 tile_id: None,
5786 }];
5787 let nearby = state.nearby_containers();
5788 let label = state.container_volume_label(&nearby[0].rows[0]);
5789 assert!(
5790 label.contains("vol 4/60"),
5791 "expected used/cap in label, got {label}"
5792 );
5793 assert!(label.contains("56 free"), "expected free space, got {label}");
5794 }
5795
5796 #[test]
5797 fn key_pair_chest_label_from_placed_lock_id() {
5798 let mut state = sample_state();
5799 let owner = uuid::Uuid::from_u128(77);
5800 state.character_id = Some(owner);
5801 let lock = uuid::Uuid::from_u128(99).to_string();
5802 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
5803 id: "chest-1".into(),
5804 template_id: "wooden_chest_small".into(),
5805 display_name: "Barry's Loot #a3f2".into(),
5806 x: 129.0,
5807 y: 128.0,
5808 z: 0.0,
5809 locked: true,
5810 accessible: true,
5811 owner_character_id: Some(owner),
5812 contents: Vec::new(),
5813 lock_id: Some(lock.clone()),
5814 capacity_volume: None,
5815 item_instance_id: Some(uuid::Uuid::from_u128(4)),
5816 tile_id: None,
5817 }];
5818 let key_id = uuid::Uuid::from_u128(5);
5819 let key = flatland_protocol::ItemStack {
5820 template_id: KEY_TEMPLATE.into(),
5821 quantity: 1,
5822 item_instance_id: Some(key_id),
5823 props: BTreeMap::from([
5824 (PROP_OPENS_LOCK_ID.into(), lock),
5825 (PROP_OPENS_CONTAINER_NAME.into(), "Barry's Loot #a3f2".into()),
5826 ]),
5827 contents: Vec::new(),
5828 display_name: Some("Container Key".into()),
5829 category: Some("key".into()),
5830 base_mass: None,
5831 base_volume: None,
5832 capacity_volume: None,
5833 stackable: None,
5834 };
5835 state.inventory_stacks = vec![key.clone()];
5836 assert_eq!(
5837 state.key_pair_chest_label(&key).as_deref(),
5838 Some("Barry's Loot #a3f2")
5839 );
5840 assert!(state.key_drop_blocked(&key));
5841 }
5842
5843 #[test]
5844 fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
5845 let mut state = sample_state();
5846 let lock = uuid::Uuid::from_u128(101).to_string();
5847 let key = flatland_protocol::ItemStack {
5848 template_id: KEY_TEMPLATE.into(),
5849 quantity: 1,
5850 item_instance_id: Some(uuid::Uuid::from_u128(7)),
5851 props: BTreeMap::from([
5852 (PROP_OPENS_LOCK_ID.into(), lock),
5853 (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
5854 ]),
5855 contents: Vec::new(),
5856 display_name: None,
5857 category: Some("key".into()),
5858 base_mass: None,
5859 base_volume: None,
5860 capacity_volume: None,
5861 stackable: None,
5862 };
5863 state.placed_containers.clear();
5864 assert_eq!(
5865 state.key_pair_chest_label(&key).as_deref(),
5866 Some("Camp Stash")
5867 );
5868 }
5869
5870 #[test]
5871 fn key_drop_allowed_when_paired_chest_unlocked() {
5872 let mut state = sample_state();
5873 let lock = uuid::Uuid::from_u128(100).to_string();
5874 let key_id = uuid::Uuid::from_u128(6);
5875 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
5876 id: "chest-1".into(),
5877 template_id: "wooden_chest_small".into(),
5878 display_name: "Camp Chest".into(),
5879 x: 129.0,
5880 y: 128.0,
5881 z: 0.0,
5882 locked: false,
5883 accessible: true,
5884 owner_character_id: None,
5885 contents: Vec::new(),
5886 lock_id: Some(lock.clone()),
5887 capacity_volume: None,
5888 item_instance_id: None,
5889 tile_id: None,
5890 }];
5891 let key = flatland_protocol::ItemStack {
5892 template_id: KEY_TEMPLATE.into(),
5893 quantity: 1,
5894 item_instance_id: Some(key_id),
5895 props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
5896 contents: Vec::new(),
5897 display_name: None,
5898 category: Some("key".into()),
5899 base_mass: None,
5900 base_volume: None,
5901 capacity_volume: None,
5902 stackable: None,
5903 };
5904 state.inventory_stacks = vec![key.clone()];
5905 assert!(!state.key_drop_blocked(&key));
5906 let opts = state.move_destinations_for(
5907 &flatland_protocol::InventoryLocation::Root,
5908 None,
5909 Some(key_id),
5910 KEY_TEMPLATE,
5911 );
5912 assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
5913 }
5914}