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