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