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