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