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