1use std::collections::{BTreeMap, VecDeque};
2use std::time::{Duration, Instant};
3
4use flatland_protocol::{
5 AbilityCooldownHud, BlueprintView, BodySlot, BuildingView, CastProgressHud, CombatHud,
6 CombatSlotHud, CombatTargetHud,
7 DoorView, EntityId, EntityState, Intent, LifeState, NpcView, RotationPreset, Seq, SessionId,
8 TerrainKindView, TerrainZoneView, Tick,
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 INTERACTION_RADIUS_M: f32 = 1.5;
30const DOOR_INTERACTION_RADIUS_M: f32 = 3.5;
31const HARVEST_CLIENT_TIMEOUT: Duration = Duration::from_secs(12);
32const CRAFT_STAMINA_COST: f32 = 3.0;
34
35#[derive(Debug, Clone, Default)]
37pub struct InventoryHint {
38 pub display_name: String,
39 pub category: String,
40 pub base_mass: Option<f32>,
41 pub base_volume: Option<f32>,
42 pub capacity_volume: Option<f32>,
43 pub stackable: bool,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum RotationEditorMode {
49 #[default]
50 List,
51 EditSequence,
52 PickAbility,
53 EditLabel,
54}
55
56#[derive(Debug, Clone, Default)]
58pub struct RotationEditorState {
59 pub mode: RotationEditorMode,
60 pub list_index: usize,
61 pub ability_index: usize,
62 pub picker_index: usize,
63 pub draft: Option<RotationPreset>,
64 pub label_buffer: String,
65}
66
67impl RotationEditorState {
68 pub fn reset(&mut self) {
69 *self = Self::default();
70 }
71}
72
73pub const CONTAINER_RANGE_M: f32 = 3.0;
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum InventorySection {
83 Worn,
85 Person,
87 Nearby,
89}
90
91pub fn body_slot_label(slot: BodySlot) -> &'static str {
94 match slot {
95 BodySlot::Head => "Head",
96 BodySlot::Body => "Body",
97 BodySlot::Arms => "Arms",
98 BodySlot::Legs => "Legs",
99 BodySlot::Feet => "Feet",
100 BodySlot::Back => "Back",
101 BodySlot::Waist => "Waist",
102 }
103}
104
105fn guess_body_slot(template_id: &str) -> Option<BodySlot> {
111 if template_id.contains("backpack") {
112 Some(BodySlot::Back)
113 } else if template_id.contains("belt") {
114 Some(BodySlot::Waist)
115 } else if template_id.contains("cap") || template_id.contains("hat") || template_id.contains("helm") {
116 Some(BodySlot::Head)
117 } else if template_id.contains("shirt") || template_id.contains("robe") || template_id.contains("vest") {
118 Some(BodySlot::Body)
119 } else if template_id.contains("sleeves") || template_id.contains("gloves") || template_id.contains("gauntlets") {
120 Some(BodySlot::Arms)
121 } else if template_id.contains("pants") || template_id.contains("leggings") {
122 Some(BodySlot::Legs)
123 } else if template_id.contains("boots") || template_id.contains("shoes") {
124 Some(BodySlot::Feet)
125 } else {
126 None
127 }
128}
129
130#[derive(Debug, Clone)]
132pub struct InventoryRow {
133 pub depth: usize,
134 pub stack: flatland_protocol::ItemStack,
135 pub from: flatland_protocol::InventoryLocation,
137 pub from_parent_instance_id: Option<uuid::Uuid>,
139 pub is_equip_shell: bool,
141 pub is_chest_shell: bool,
143 pub section: InventorySection,
144}
145
146#[derive(Debug, Clone)]
149pub struct NearbyContainer {
150 pub view: flatland_protocol::PlacedContainerView,
151 pub distance_m: f32,
152 pub rows: Vec<InventoryRow>,
153}
154
155#[derive(Debug, Clone)]
157pub struct MoveOption {
158 pub label: String,
159 pub kind: MoveOptionKind,
160}
161
162#[derive(Debug, Clone, PartialEq)]
163pub enum MoveOptionKind {
164 Move {
165 location: flatland_protocol::InventoryLocation,
166 parent_instance_id: Option<uuid::Uuid>,
167 },
168 Drop,
169 Cancel,
170}
171
172#[derive(Debug, Clone)]
174pub struct MovePicker {
175 pub item_instance_id: uuid::Uuid,
176 pub from: flatland_protocol::InventoryLocation,
177 pub item_label: String,
178 pub template_id: String,
179 pub stack_quantity: u32,
180 pub quantity: u32,
181 pub options: Vec<MoveOption>,
182}
183
184#[derive(Debug, Clone)]
186pub struct DestroyPicker {
187 pub item_instance_id: uuid::Uuid,
188 pub from: flatland_protocol::InventoryLocation,
189 pub item_label: String,
190 pub stack_quantity: u32,
191 pub quantity: u32,
192}
193
194fn push_inventory_rows(
195 rows: &mut Vec<InventoryRow>,
196 depth: usize,
197 stack: &flatland_protocol::ItemStack,
198 from: &flatland_protocol::InventoryLocation,
199 from_parent_instance_id: Option<uuid::Uuid>,
200 section: InventorySection,
201) {
202 rows.push(InventoryRow {
203 depth,
204 stack: stack.clone(),
205 from: from.clone(),
206 from_parent_instance_id,
207 is_equip_shell: false,
208 is_chest_shell: false,
209 section,
210 });
211 for child in &stack.contents {
212 push_inventory_rows(
213 rows,
214 depth + 1,
215 child,
216 from,
217 stack.item_instance_id,
218 section,
219 );
220 }
221}
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
224pub enum ShopTab {
225 #[default]
226 Buy,
227 Sell,
228}
229
230#[derive(Debug, Clone)]
231pub struct GameState {
232 pub session_id: SessionId,
233 pub entity_id: EntityId,
234 pub character_id: Option<uuid::Uuid>,
236 pub tick: Tick,
237 pub chunk_rev: u64,
238 pub content_rev: u64,
239 pub entities: Vec<EntityState>,
240 pub player: Option<EntityState>,
241 pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
242 pub ground_drops: Vec<flatland_protocol::GroundDropView>,
243 pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
244 pub buildings: Vec<BuildingView>,
245 pub doors: Vec<DoorView>,
246 pub npcs: Vec<NpcView>,
247 pub blueprints: Vec<BlueprintView>,
248 pub world_width_m: f32,
249 pub world_height_m: f32,
250 pub terrain_zones: Vec<TerrainZoneView>,
251 pub world_clock: flatland_protocol::WorldClock,
252 pub inventory: std::collections::HashMap<String, u32>,
253 pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
254 pub logs: VecDeque<String>,
255 pub intents_sent: u64,
256 pub ticks_received: u64,
257 pub connected: bool,
258 pub disconnect_reason: Option<String>,
259 pub show_stats: bool,
260 pub show_craft_menu: bool,
261 pub craft_menu_index: usize,
262 pub craft_batch_quantity: u32,
264 pub show_shop_menu: bool,
265 pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
266 pub shop_tab: ShopTab,
267 pub shop_menu_index: usize,
268 pub shop_quantity: u32,
269 pub show_inventory_menu: bool,
270 pub inventory_menu_index: usize,
271 pub show_move_picker: bool,
272 pub move_picker_index: usize,
273 pub move_picker: Option<MovePicker>,
274 pub show_destroy_picker: bool,
275 pub destroy_confirm_pending: bool,
276 pub destroy_picker: Option<DestroyPicker>,
277 pub show_rename_prompt: bool,
279 pub rename_buffer: String,
280 pub combat_target: Option<EntityId>,
282 pub combat_target_label: Option<String>,
283 pub in_combat: bool,
284 pub auto_attack: bool,
285 pub combat_has_los: bool,
286 pub attack_cd_ticks: u64,
287 pub gcd_ticks: u64,
288 pub weapon_ability_id: String,
289 pub mainhand_template_id: Option<String>,
290 pub mainhand_label: Option<String>,
291 pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
294 pub carry_mass: f32,
295 pub carry_mass_max: f32,
296 pub encumbrance: flatland_protocol::EncumbranceState,
297 pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
299 pub combat_target_detail: Option<CombatTargetHud>,
300 pub cast_progress: Option<CastProgressHud>,
301 pub ability_cooldowns: Vec<AbilityCooldownHud>,
302 pub blocking_active: bool,
303 pub max_target_slots: u8,
304 pub combat_slots: Vec<CombatSlotHud>,
305 pub rotation_presets: Vec<RotationPreset>,
306 pub show_loadout_menu: bool,
307 pub show_rotation_editor: bool,
308 pub loadout_menu_index: usize,
309 pub rotation_editor: RotationEditorState,
310 pub harvest_in_progress: bool,
312 pub harvest_started_at: Option<Instant>,
314 pub pending_craft_ack: Option<(u32, String, u32)>,
316}
317
318impl GameState {
319 pub fn push_log(&mut self, line: impl Into<String>) {
320 self.logs.push_back(line.into());
321 while self.logs.len() > MAX_LOG_LINES {
322 self.logs.pop_front();
323 }
324 }
325
326 pub fn is_alive(&self) -> bool {
327 self.player
328 .as_ref()
329 .and_then(|p| p.vitals)
330 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
331 .unwrap_or(true)
332 }
333
334 pub fn clear_harvest_state(&mut self) {
335 self.harvest_in_progress = false;
336 self.harvest_started_at = None;
337 }
338
339 fn harvest_state_stale(&self) -> bool {
340 match self.harvest_started_at {
341 Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
342 None => self.harvest_in_progress,
343 }
344 }
345
346 pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
347 self.player.as_ref().and_then(|p| p.vitals)
348 }
349
350 pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
351 let materials_ok = blueprint.inputs.iter().all(|input| {
352 self.inventory
353 .get(&input.template_id)
354 .copied()
355 .unwrap_or(0)
356 >= input.quantity
357 });
358 let tools_ok = blueprint.required_tools.iter().all(|tool| {
359 self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1
360 });
361 let station_ok = match blueprint.station.as_deref() {
362 None | Some("hand") => true,
363 Some(tag) => self.player_at_station_tag(tag),
364 };
365 materials_ok && tools_ok && station_ok
366 }
367
368 pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
369 if !self.can_craft_blueprint(blueprint) {
370 return 0;
371 }
372 let mut limit = u32::MAX;
373 for input in &blueprint.inputs {
374 if input.quantity == 0 {
375 continue;
376 }
377 let have = self
378 .inventory
379 .get(&input.template_id)
380 .copied()
381 .unwrap_or(0);
382 limit = limit.min(have / input.quantity);
383 }
384 for tool in &blueprint.required_tools {
385 if tool.consumed {
386 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
387 limit = limit.min(have);
388 }
389 }
390 let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
391 if CRAFT_STAMINA_COST > 0.0 {
392 limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
393 }
394 limit
395 }
396
397 pub fn clamp_craft_batch_quantity(&mut self) {
398 let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
399 self.craft_batch_quantity = 1;
400 return;
401 };
402 let max = self.max_craft_batches(bp).max(1);
403 self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
404 }
405
406 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
407 let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
408 return;
409 };
410 let max = self.max_craft_batches(&bp).max(1);
411 let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
412 self.craft_batch_quantity = next as u32;
413 }
414
415 pub fn craft_batch_set_max(&mut self) {
416 let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
417 return;
418 };
419 let max = self.max_craft_batches(&bp);
420 self.craft_batch_quantity = if max == 0 { 1 } else { max };
421 }
422
423 pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
424 let preserve_ui = self.show_shop_menu;
425 let tab = self.shop_tab;
426 let index = self.shop_menu_index;
427 let qty = self.shop_quantity;
428
429 self.show_shop_menu = true;
430 self.show_craft_menu = false;
431 self.show_inventory_menu = false;
432 self.show_stats = false;
433 self.shop_catalog = Some(catalog);
434
435 if preserve_ui {
436 self.shop_tab = tab;
437 self.shop_menu_index = index;
438 self.shop_quantity = qty;
439 } else {
440 self.shop_tab = ShopTab::Buy;
441 self.shop_menu_index = 0;
442 self.shop_quantity = 1;
443 }
444 self.clamp_shop_selection();
445 }
446
447 pub fn shop_list_len(&self) -> usize {
448 let Some(catalog) = &self.shop_catalog else {
449 return 0;
450 };
451 match self.shop_tab {
452 ShopTab::Buy => catalog.sells.len(),
453 ShopTab::Sell => catalog.buys.len(),
454 }
455 }
456
457 pub fn shop_menu_move(&mut self, delta: i32) {
458 let n = self.shop_list_len();
459 if n == 0 {
460 return;
461 }
462 let idx = self.shop_menu_index as i32;
463 let next = (idx + delta).rem_euclid(n as i32);
464 self.shop_menu_index = next as usize;
465 self.clamp_shop_quantity();
466 }
467
468 pub fn shop_quantity_adjust(&mut self, delta: i32) {
469 let max = self.shop_quantity_max();
470 let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
471 self.shop_quantity = next as u32;
472 }
473
474 pub(crate) fn clamp_shop_selection(&mut self) {
475 let n = self.shop_list_len();
476 if n == 0 {
477 self.shop_menu_index = 0;
478 } else {
479 self.shop_menu_index = self.shop_menu_index.min(n - 1);
480 }
481 self.clamp_shop_quantity();
482 }
483
484 fn shop_quantity_max(&self) -> u32 {
485 let Some(catalog) = &self.shop_catalog else {
486 return 1;
487 };
488 match self.shop_tab {
489 ShopTab::Buy => {
490 if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
491 if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
492 return 1;
493 }
494 }
495 99
496 }
497 ShopTab::Sell => catalog
498 .buys
499 .get(self.shop_menu_index)
500 .map(|l| l.quantity)
501 .unwrap_or(1)
502 .max(1),
503 }
504 }
505
506 pub fn shop_quantity_set_max(&mut self) {
507 self.shop_quantity = self.shop_quantity_max();
508 }
509
510 fn clamp_shop_quantity(&mut self) {
511 self.shop_quantity = self.shop_quantity.clamp(1, self.shop_quantity_max());
512 }
513
514 pub fn player_at_station_tag(&self, tag: &str) -> bool {
515 let (px, py) = self.player_position();
516 self.building_interior_at(px, py)
517 .is_some_and(|b| b.tags.iter().any(|t| t == tag))
518 }
519
520 pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
522 if self.can_craft_blueprint(blueprint) {
523 return None;
524 }
525 let mut missing = Vec::new();
526 for input in &blueprint.inputs {
527 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
528 if have < input.quantity {
529 missing.push(format!("{}×{} (have {have})", input.quantity, input.template_id));
530 }
531 }
532 for tool in &blueprint.required_tools {
533 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
534 if have < 1 {
535 missing.push(format!("tool: {}", tool.item));
536 }
537 }
538 if let Some(station) = blueprint.station.as_deref() {
539 if station != "hand" && !self.player_at_station_tag(station) {
540 missing.push(format!("station: {station} (enter building)"));
541 }
542 }
543 if missing.is_empty() {
544 None
545 } else {
546 Some(missing.join(", "))
547 }
548 }
549
550 pub fn player_entity(&self) -> Option<&EntityState> {
551 self.player
552 .as_ref()
553 .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
554 }
555
556 pub fn player_position(&self) -> (f32, f32) {
557 self.player_entity()
558 .map(|p| (p.transform.position.x, p.transform.position.y))
559 .unwrap_or((0.0, 0.0))
560 }
561
562 pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
563 let mut rows: Vec<(String, u32, String)> = self
564 .inventory
565 .iter()
566 .filter(|(_, q)| **q > 0)
567 .map(|(id, qty)| {
568 let label = self
569 .inventory_hints
570 .get(id)
571 .map(|h| h.display_name.clone())
572 .unwrap_or_else(|| id.clone());
573 (id.clone(), *qty, label)
574 })
575 .collect();
576 rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
577 rows
578 }
579
580 pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
581 self.inventory_hints
582 .get(template_id)
583 .map(|h| h.category.as_str())
584 .filter(|c| !c.is_empty())
585 }
586
587 pub fn item_base_mass(&self, template_id: &str) -> f32 {
588 self.inventory_hints
589 .get(template_id)
590 .and_then(|h| h.base_mass)
591 .unwrap_or(0.5)
592 }
593
594 pub fn item_base_volume(&self, template_id: &str) -> f32 {
595 self.inventory_hints
596 .get(template_id)
597 .and_then(|h| h.base_volume)
598 .unwrap_or(1.0)
599 }
600
601 pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
602 let unit = stack
603 .base_mass
604 .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
605 unit * stack.quantity as f32
606 }
607
608 fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
609 let unit = stack.base_volume.unwrap_or(1.0);
610 unit * stack.quantity as f32
611 + stack
612 .contents
613 .iter()
614 .map(Self::stack_tree_volume)
615 .sum::<f32>()
616 }
617
618 fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
619 contents.iter().map(Self::stack_tree_volume).sum()
620 }
621
622 fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
623 self.inventory_hints
624 .get(template_id)
625 .and_then(|h| h.capacity_volume)
626 .filter(|c| *c > 0.0)
627 }
628
629 fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
630 stack
631 .capacity_volume
632 .filter(|c| *c > 0.0)
633 .or_else(|| self.template_capacity_volume(&stack.template_id))
634 }
635
636 pub fn container_volume_label(&self, row: &InventoryRow) -> String {
638 let Some((used, cap)) = self.container_volume_stats(row) else {
639 return String::new();
640 };
641 let free = (cap - used).max(0.0);
642 format!(" vol {used:.0}/{cap:.0} ({free:.0} free)")
643 }
644
645 fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
646 if row.is_chest_shell {
647 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
648 return None;
649 };
650 let chest = self
651 .placed_containers
652 .iter()
653 .find(|c| c.id == *container_id)?;
654 let cap = self
655 .stack_capacity_volume(&row.stack)
656 .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
657 let used = if chest.accessible {
658 Self::contents_used_volume(&chest.contents)
659 } else {
660 0.0
661 };
662 return Some((used, cap));
663 }
664
665 let cap = self.stack_capacity_volume(&row.stack)?;
666 let used = Self::contents_used_volume(&row.stack.contents);
667 Some((used, cap))
668 }
669
670 pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
671 if row.is_chest_shell {
672 return true;
673 }
674 if row.is_equip_shell {
675 return self.inventory_item_category(&row.stack.template_id) == Some("container");
676 }
677 self.inventory_item_category(&row.stack.template_id) == Some("container")
678 || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
679 }
680
681 fn container_stack_for(
682 &self,
683 location: &flatland_protocol::InventoryLocation,
684 parent_instance_id: Option<uuid::Uuid>,
685 ) -> Option<flatland_protocol::ItemStack> {
686 match location {
687 flatland_protocol::InventoryLocation::Root => {
688 let pid = parent_instance_id?;
689 self.find_stack_by_instance(&self.inventory_stacks, pid)
690 }
691 flatland_protocol::InventoryLocation::Worn { slot } => {
692 let worn = self.worn.get(slot)?;
693 if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
694 Some(worn.clone())
695 } else {
696 self.find_stack_by_instance(&worn.contents, parent_instance_id?)
697 }
698 }
699 flatland_protocol::InventoryLocation::Placed { container_id } => {
700 let chest = self.placed_containers.iter().find(|c| c.id == *container_id)?;
701 if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
702 Some(flatland_protocol::ItemStack {
703 template_id: chest.template_id.clone(),
704 quantity: 1,
705 item_instance_id: chest.item_instance_id,
706 props: Default::default(),
707 contents: chest.contents.clone(),
708 display_name: Some(chest.display_name.clone()),
709 category: Some("container".into()),
710 base_mass: None,
711 base_volume: None,
712 capacity_volume: self
713 .inventory_hints
714 .get(&chest.template_id)
715 .and_then(|h| h.capacity_volume),
716 stackable: None,
717 })
718 } else {
719 self.find_stack_by_instance(&chest.contents, parent_instance_id?)
720 }
721 }
722 }
723 }
724
725 fn find_stack_by_instance(
726 &self,
727 stacks: &[flatland_protocol::ItemStack],
728 instance_id: uuid::Uuid,
729 ) -> Option<flatland_protocol::ItemStack> {
730 for stack in stacks {
731 if stack.item_instance_id == Some(instance_id) {
732 return Some(stack.clone());
733 }
734 if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
735 return Some(found);
736 }
737 }
738 None
739 }
740
741 pub fn max_movable_to(
743 &self,
744 template_id: &str,
745 stack_qty: u32,
746 from: &flatland_protocol::InventoryLocation,
747 to: &flatland_protocol::InventoryLocation,
748 parent_instance_id: Option<uuid::Uuid>,
749 ) -> u32 {
750 let unit_vol = self.item_base_volume(template_id);
751 let unit_mass = self.item_base_mass(template_id);
752 let mut limit = stack_qty;
753
754 if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
755 let cap = parent
756 .capacity_volume
757 .or_else(|| {
758 self.inventory_hints
759 .get(&parent.template_id)
760 .and_then(|h| h.capacity_volume)
761 })
762 .unwrap_or(0.0);
763 if cap > 0.0 && unit_vol > 0.0 {
764 let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
765 limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
766 }
767 }
768
769 let to_person = matches!(
770 to,
771 flatland_protocol::InventoryLocation::Root | flatland_protocol::InventoryLocation::Worn { .. }
772 );
773 let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
774 if to_person && from_placed && unit_mass > 0.0 {
775 let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
776 if self.encumbrance == flatland_protocol::EncumbranceState::Over {
777 limit = 0;
778 } else {
779 limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
780 }
781 }
782
783 limit.max(0).min(stack_qty)
784 }
785
786 pub fn move_picker_max_at_selection(&self) -> u32 {
787 let Some(picker) = &self.move_picker else {
788 return 1;
789 };
790 let Some(opt) = picker.options.get(self.move_picker_index) else {
791 return picker.stack_quantity;
792 };
793 match &opt.kind {
794 MoveOptionKind::Cancel | MoveOptionKind::Drop => picker.stack_quantity,
795 MoveOptionKind::Move {
796 location,
797 parent_instance_id,
798 } => self.max_movable_to(
799 &picker.template_id,
800 picker.stack_quantity,
801 &picker.from,
802 location,
803 *parent_instance_id,
804 ),
805 }
806 }
807
808 pub fn clamp_move_picker_quantity(&mut self) {
809 let max = self.move_picker_max_at_selection();
810 if let Some(picker) = &mut self.move_picker {
811 if max == 0 {
812 picker.quantity = 1;
813 } else {
814 picker.quantity = picker.quantity.clamp(1, max);
815 }
816 }
817 }
818
819 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
820 let max = self.move_picker_max_at_selection().max(1);
821 if let Some(picker) = &mut self.move_picker {
822 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
823 picker.quantity = next as u32;
824 }
825 }
826
827 pub fn move_picker_set_quantity_max(&mut self) {
828 let max = self.move_picker_max_at_selection();
829 if let Some(picker) = &mut self.move_picker {
830 picker.quantity = if max == 0 { 1 } else { max.min(picker.stack_quantity) };
831 }
832 }
833
834 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
835 if let Some(picker) = &mut self.destroy_picker {
836 let max = picker.stack_quantity.max(1);
837 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
838 picker.quantity = next as u32;
839 }
840 }
841
842 pub fn destroy_picker_set_quantity_max(&mut self) {
843 if let Some(picker) = &mut self.destroy_picker {
844 picker.quantity = picker.stack_quantity.max(1);
845 }
846 }
847
848 pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
849 let have = self.inventory.get(template_id).copied().unwrap_or(0);
850 (have, have >= need)
851 }
852
853 pub fn currency_display(&self) -> String {
854 crate::currency::currency_line(&self.inventory)
855 }
856
857 pub fn in_shallow_water(&self) -> bool {
859 let (px, py) = self.player_position();
860 self.terrain_at(px, py)
861 .is_some_and(|k| k == TerrainKindView::ShallowWater)
862 }
863
864 pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
865 self.terrain_zones.iter().find_map(|z| {
866 if x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1 {
867 Some(z.kind)
868 } else {
869 None
870 }
871 })
872 }
873
874 pub fn building_interior_at(&self, px: f32, py: f32) -> Option<&BuildingView> {
876 self.buildings
877 .iter()
878 .find(|b| point_in_building_interior(px, py, b))
879 }
880
881 pub fn effective_inside_building(&self) -> Option<String> {
883 let (px, py) = self.player_position();
884 if let Some(b) = self.building_interior_at(px, py) {
885 return Some(b.id.clone());
886 }
887 if is_instanced_world_coord(px, py, self.world_width_m, self.world_height_m) {
888 if let Some(id) = self
889 .player_entity()
890 .and_then(|p| p.inside_building.clone())
891 {
892 return Some(id);
893 }
894 if self.buildings.len() == 1 {
895 return Some(self.buildings[0].id.clone());
896 }
897 }
898 None
899 }
900
901 pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
902 self.inventory_stacks = stacks.to_vec();
903 self.inventory.clear();
904 self.inventory_hints.clear();
905 fn walk(
906 stacks: &[flatland_protocol::ItemStack],
907 inventory: &mut std::collections::HashMap<String, u32>,
908 hints: &mut std::collections::HashMap<String, InventoryHint>,
909 ) {
910 for stack in stacks {
911 *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
912 if stack.display_name.is_some()
913 || stack.category.is_some()
914 || stack.base_mass.is_some()
915 || stack.base_volume.is_some()
916 {
917 hints.insert(
918 stack.template_id.clone(),
919 InventoryHint {
920 display_name: stack
921 .display_name
922 .clone()
923 .unwrap_or_else(|| stack.template_id.clone()),
924 category: stack.category.clone().unwrap_or_default(),
925 base_mass: stack.base_mass,
926 base_volume: stack.base_volume,
927 capacity_volume: stack.capacity_volume,
928 stackable: stack.stackable.unwrap_or(true),
929 },
930 );
931 }
932 walk(&stack.contents, inventory, hints);
933 }
934 }
935 walk(stacks, &mut self.inventory, &mut self.inventory_hints);
936 for item in self.worn.values() {
938 walk(std::slice::from_ref(item), &mut self.inventory, &mut self.inventory_hints);
939 }
940 }
941
942 pub fn worn_rows(&self) -> Vec<InventoryRow> {
947 let mut rows = Vec::new();
948 for (slot, item) in &self.worn {
949 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
950 rows.push(InventoryRow {
951 depth: 0,
952 stack: item.clone(),
953 from: from.clone(),
954 from_parent_instance_id: None,
955 is_equip_shell: true,
956 is_chest_shell: false,
957 section: InventorySection::Worn,
958 });
959 for child in &item.contents {
960 push_inventory_rows(
961 &mut rows,
962 1,
963 child,
964 &from,
965 item.item_instance_id,
966 InventorySection::Worn,
967 );
968 }
969 }
970 rows
971 }
972
973 pub fn person_rows(&self) -> Vec<InventoryRow> {
975 let mut rows = Vec::new();
976 for stack in &self.inventory_stacks {
977 push_inventory_rows(
978 &mut rows,
979 0,
980 stack,
981 &flatland_protocol::InventoryLocation::Root,
982 None,
983 InventorySection::Person,
984 );
985 }
986 rows
987 }
988
989 pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
991 let mut rows = self.worn_rows();
992 rows.extend(self.person_rows());
993 rows.into_iter().map(|r| (r.depth, r.stack)).collect()
994 }
995
996 pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
1000 let (px, py) = self.player_position();
1001 let mut list: Vec<NearbyContainer> = self
1002 .placed_containers
1003 .iter()
1004 .filter_map(|c| {
1005 let distance_m = (c.x - px).hypot(c.y - py);
1006 if distance_m > CONTAINER_RANGE_M {
1007 return None;
1008 }
1009 let mut rows = Vec::new();
1010 let from = flatland_protocol::InventoryLocation::Placed {
1011 container_id: c.id.clone(),
1012 };
1013 rows.push(InventoryRow {
1014 depth: 0,
1015 stack: flatland_protocol::ItemStack {
1016 template_id: c.template_id.clone(),
1017 quantity: 1,
1018 item_instance_id: c.item_instance_id,
1019 props: Default::default(),
1020 contents: Vec::new(),
1021 display_name: Some(c.display_name.clone()),
1022 category: Some("container".into()),
1023 base_mass: None,
1024 base_volume: None,
1025 capacity_volume: c.capacity_volume,
1026 stackable: None,
1027 },
1028 from: from.clone(),
1029 from_parent_instance_id: None,
1030 is_equip_shell: false,
1031 is_chest_shell: true,
1032 section: InventorySection::Nearby,
1033 });
1034 if c.accessible {
1035 for child in &c.contents {
1036 push_inventory_rows(
1037 &mut rows,
1038 1,
1039 child,
1040 &from,
1041 c.item_instance_id,
1042 InventorySection::Nearby,
1043 );
1044 }
1045 }
1046 Some(NearbyContainer {
1047 view: c.clone(),
1048 distance_m,
1049 rows,
1050 })
1051 })
1052 .collect();
1053 list.sort_by(|a, b| {
1054 a.distance_m
1055 .partial_cmp(&b.distance_m)
1056 .unwrap_or(std::cmp::Ordering::Equal)
1057 });
1058 list
1059 }
1060
1061 pub fn nearest_placed_container(
1063 &self,
1064 max_dist: f32,
1065 ) -> Option<flatland_protocol::PlacedContainerView> {
1066 let (px, py) = self.player_position();
1067 self.placed_containers
1068 .iter()
1069 .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
1070 .min_by(|a, b| {
1071 let da = (a.x - px).hypot(a.y - py);
1072 let db = (b.x - px).hypot(b.y - py);
1073 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
1074 })
1075 .cloned()
1076 }
1077
1078 pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
1084 let mut rows = self.worn_rows();
1085 rows.extend(self.person_rows());
1086 for nc in self.nearby_containers() {
1087 rows.extend(nc.rows);
1088 }
1089 rows
1090 }
1091
1092 pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
1093 self.inventory_selectable_rows()
1094 .into_iter()
1095 .nth(self.inventory_menu_index)
1096 }
1097
1098 pub fn move_destinations_for(
1100 &self,
1101 from: &flatland_protocol::InventoryLocation,
1102 from_parent_instance_id: Option<uuid::Uuid>,
1103 moving_instance_id: Option<uuid::Uuid>,
1104 moving_template_id: &str,
1105 ) -> Vec<MoveOption> {
1106 let mut opts = Vec::new();
1107 if *from != flatland_protocol::InventoryLocation::Root {
1108 opts.push(MoveOption {
1109 label: "On your person (loose)".into(),
1110 kind: MoveOptionKind::Move {
1111 location: flatland_protocol::InventoryLocation::Root,
1112 parent_instance_id: None,
1113 },
1114 });
1115 }
1116 for (slot, item) in &self.worn {
1117 if item.category.as_deref() != Some("container") {
1118 continue;
1119 }
1120 let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
1121 let shell_name = item
1122 .display_name
1123 .clone()
1124 .unwrap_or_else(|| item.template_id.clone());
1125
1126 if *slot != BodySlot::Waist
1128 && item.item_instance_id != moving_instance_id
1129 && Self::is_volume_container_stack(item)
1130 {
1131 Self::push_move_destination(
1132 &mut opts,
1133 format!("{shell_name} (worn {})", body_slot_label(*slot)),
1134 location.clone(),
1135 item.item_instance_id,
1136 from,
1137 from_parent_instance_id,
1138 );
1139 }
1140
1141 if *slot == BodySlot::Waist
1143 && Self::attaches_to_belt_loop(moving_template_id)
1144 && item.item_instance_id != moving_instance_id
1145 {
1146 Self::push_move_destination(
1147 &mut opts,
1148 format!("{shell_name} (belt loop)"),
1149 location.clone(),
1150 item.item_instance_id,
1151 from,
1152 from_parent_instance_id,
1153 );
1154 }
1155
1156 let context = if *slot == BodySlot::Waist {
1157 format!("on {shell_name}")
1158 } else {
1159 format!("in {shell_name}")
1160 };
1161 Self::append_nested_container_destinations(
1162 &mut opts,
1163 location,
1164 item,
1165 &context,
1166 from,
1167 from_parent_instance_id,
1168 moving_instance_id,
1169 );
1170 }
1171 for nc in self.nearby_containers() {
1172 if !nc.view.accessible {
1173 continue;
1174 }
1175 let location = flatland_protocol::InventoryLocation::Placed {
1176 container_id: nc.view.id.clone(),
1177 };
1178 Self::push_move_destination(
1179 &mut opts,
1180 format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
1181 location,
1182 nc.view.item_instance_id,
1183 from,
1184 from_parent_instance_id,
1185 );
1186 }
1187 let allow_drop = moving_instance_id
1188 .and_then(|id| self.stack_for_instance(id))
1189 .map(|stack| !self.key_drop_blocked(&stack))
1190 .unwrap_or(moving_template_id != KEY_TEMPLATE);
1191 if allow_drop {
1192 opts.push(MoveOption {
1193 label: "Drop on the ground".into(),
1194 kind: MoveOptionKind::Drop,
1195 });
1196 }
1197 opts.push(MoveOption {
1198 label: "Cancel".into(),
1199 kind: MoveOptionKind::Cancel,
1200 });
1201 opts
1202 }
1203
1204 fn is_same_container_dest(
1205 dest_location: &flatland_protocol::InventoryLocation,
1206 dest_parent: Option<uuid::Uuid>,
1207 from: &flatland_protocol::InventoryLocation,
1208 from_parent: Option<uuid::Uuid>,
1209 ) -> bool {
1210 dest_location == from && dest_parent == from_parent
1211 }
1212
1213 fn push_move_destination(
1214 opts: &mut Vec<MoveOption>,
1215 label: String,
1216 location: flatland_protocol::InventoryLocation,
1217 parent_instance_id: Option<uuid::Uuid>,
1218 from: &flatland_protocol::InventoryLocation,
1219 from_parent_instance_id: Option<uuid::Uuid>,
1220 ) {
1221 if Self::is_same_container_dest(
1222 &location,
1223 parent_instance_id,
1224 from,
1225 from_parent_instance_id,
1226 ) {
1227 return;
1228 }
1229 opts.push(MoveOption {
1230 label,
1231 kind: MoveOptionKind::Move {
1232 location,
1233 parent_instance_id,
1234 },
1235 });
1236 }
1237
1238 fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
1239 stack.capacity_volume.is_some_and(|c| c > 0.0)
1240 }
1241
1242 fn attaches_to_belt_loop(template_id: &str) -> bool {
1243 matches!(template_id, "leather_pouch" | "dimensional_pouch")
1244 }
1245
1246 fn append_nested_container_destinations(
1247 opts: &mut Vec<MoveOption>,
1248 location: flatland_protocol::InventoryLocation,
1249 container: &flatland_protocol::ItemStack,
1250 context: &str,
1251 from: &flatland_protocol::InventoryLocation,
1252 from_parent_instance_id: Option<uuid::Uuid>,
1253 moving_instance_id: Option<uuid::Uuid>,
1254 ) {
1255 for child in &container.contents {
1256 if Self::is_volume_container_stack(child)
1257 && child.item_instance_id != moving_instance_id
1258 {
1259 let name = child
1260 .display_name
1261 .clone()
1262 .unwrap_or_else(|| child.template_id.clone());
1263 Self::push_move_destination(
1264 opts,
1265 format!("{name} ({context})"),
1266 location.clone(),
1267 child.item_instance_id,
1268 from,
1269 from_parent_instance_id,
1270 );
1271 }
1272 let nested_context = format!(
1273 "in {}",
1274 child
1275 .display_name
1276 .as_deref()
1277 .unwrap_or(&child.template_id)
1278 );
1279 Self::append_nested_container_destinations(
1280 opts,
1281 location.clone(),
1282 child,
1283 &nested_context,
1284 from,
1285 from_parent_instance_id,
1286 moving_instance_id,
1287 );
1288 }
1289 }
1290
1291 fn clamp_inventory_indices(&mut self) {
1292 let n = self.inventory_selectable_rows().len();
1293 self.inventory_menu_index = if n == 0 {
1294 0
1295 } else {
1296 self.inventory_menu_index.min(n - 1)
1297 };
1298 if let Some(picker) = &self.move_picker {
1299 let pn = picker.options.len();
1300 self.move_picker_index = if pn == 0 {
1301 0
1302 } else {
1303 self.move_picker_index.min(pn - 1)
1304 };
1305 }
1306 }
1307
1308 fn apply_snapshot_fields(&mut self, snapshot: &flatland_protocol::Snapshot, entity_id: EntityId) {
1309 self.tick = snapshot.tick;
1310 self.chunk_rev = snapshot.chunk_rev;
1311 self.content_rev = snapshot.content_rev;
1312 self.resource_nodes = snapshot.resource_nodes.clone();
1313 self.ground_drops = snapshot.ground_drops.clone();
1314 self.placed_containers = snapshot.placed_containers.clone();
1315 self.world_width_m = snapshot.world_width_m;
1316 self.world_height_m = snapshot.world_height_m;
1317 self.world_clock = snapshot.world_clock;
1318 self.terrain_zones = snapshot.terrain_zones.clone();
1319 self.buildings = snapshot.buildings.clone();
1320 self.doors = snapshot.doors.clone();
1321 self.npcs = snapshot.npcs.clone();
1322 self.blueprints = snapshot.blueprints.clone();
1323 self.sync_inventory_from_stacks(&snapshot.inventory);
1324 self.player = snapshot
1325 .entities
1326 .iter()
1327 .find(|e| e.id == entity_id)
1328 .cloned();
1329 self.entities = snapshot.entities.clone();
1330 }
1331
1332 fn refresh_inventory_ui(&mut self) {
1336 if let Some(picker) = &self.move_picker {
1337 let instance_id = picker.item_instance_id;
1338 let still_exists = self
1339 .inventory_selectable_rows()
1340 .iter()
1341 .any(|r| r.stack.item_instance_id == Some(instance_id));
1342 if !still_exists {
1343 self.move_picker = None;
1344 self.show_move_picker = false;
1345 }
1346 }
1347 if let Some(picker) = &self.destroy_picker {
1348 let instance_id = picker.item_instance_id;
1349 let still_exists = self
1350 .inventory_selectable_rows()
1351 .iter()
1352 .any(|r| r.stack.item_instance_id == Some(instance_id));
1353 if !still_exists {
1354 self.destroy_picker = None;
1355 self.show_destroy_picker = false;
1356 self.destroy_confirm_pending = false;
1357 }
1358 }
1359 self.clamp_inventory_indices();
1360 }
1361
1362 fn apply_combat_hud(&mut self, combat: &CombatHud) {
1363 self.in_combat = combat.in_combat;
1364 self.auto_attack = combat.auto_attack;
1365 self.combat_has_los = combat.has_los;
1366 self.attack_cd_ticks = combat.attack_cd_ticks;
1367 self.gcd_ticks = combat.gcd_ticks;
1368 self.weapon_ability_id = combat.ability_id.clone();
1369 self.mainhand_template_id = combat.mainhand_template_id.clone();
1370 self.mainhand_label = combat.mainhand_label.clone();
1371 self.worn = combat.worn.iter().cloned().collect();
1372 self.carry_mass = combat.carry_mass;
1373 self.carry_mass_max = combat.carry_mass_max;
1374 self.encumbrance = combat.encumbrance;
1375 self.cast_progress = combat.cast.clone();
1376 self.ability_cooldowns = combat.ability_cooldowns.clone();
1377 self.blocking_active = combat.blocking_active;
1378 self.max_target_slots = combat.max_target_slots.max(1);
1379 self.combat_slots = combat.slots.clone();
1380 self.rotation_presets = combat.rotation_presets.clone();
1381 self.combat_target_detail = combat.target.clone();
1382 self.combat_target = combat.target_entity_id;
1383 if let Some(label) = &combat.target_label {
1384 self.combat_target_label = Some(label.clone());
1385 } else if let Some(id) = combat.target_entity_id {
1386 self.combat_target_label = self
1387 .entities
1388 .iter()
1389 .find(|e| e.id == id)
1390 .map(|e| e.label.clone())
1391 .or_else(|| self.combat_target_label.clone());
1392 }
1393 self.refresh_inventory_ui();
1394 }
1395
1396 pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
1398 self.combat_slots
1399 .iter()
1400 .find(|s| s.slot_index == slot)
1401 .and_then(|s| s.target_entity_id)
1402 .or_else(|| {
1403 if slot == 1 {
1404 self.combat_target
1405 } else {
1406 None
1407 }
1408 })
1409 }
1410
1411 pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
1413 self.combat_candidates()
1414 }
1415
1416 pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
1418 let (px, py) = self.player_position();
1419 let dist = |id: EntityId| {
1420 self.entities
1421 .iter()
1422 .find(|e| e.id == id)
1423 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
1424 .unwrap_or(f32::MAX)
1425 };
1426
1427 let mut allies = Vec::new();
1428 if let Some(me) = self.player.as_ref() {
1430 let alive = me
1431 .vitals
1432 .as_ref()
1433 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1434 .unwrap_or(true);
1435 if alive {
1436 allies.push((self.entity_id, "Yourself".into()));
1437 }
1438 }
1439 for entity in &self.entities {
1440 if entity.id == self.entity_id {
1441 continue;
1442 }
1443 if entity.vitals.is_some() {
1444 let alive = entity
1445 .vitals
1446 .as_ref()
1447 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1448 .unwrap_or(true);
1449 if alive {
1450 allies.push((entity.id, entity.label.clone()));
1451 }
1452 }
1453 }
1454 allies.sort_by(|(a, _), (b, _)| {
1455 if *a == self.entity_id {
1456 return std::cmp::Ordering::Less;
1457 }
1458 if *b == self.entity_id {
1459 return std::cmp::Ordering::Greater;
1460 }
1461 dist(*a)
1462 .partial_cmp(&dist(*b))
1463 .unwrap_or(std::cmp::Ordering::Equal)
1464 });
1465
1466 let mut monsters = self.combat_candidates();
1467 monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
1468 allies.into_iter().chain(monsters).collect()
1469 }
1470
1471 fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
1472 match slot_index {
1473 2 => self.t2_candidates(),
1474 _ => self.t1_candidates(),
1475 }
1476 }
1477
1478 pub(crate) fn restore_from_welcome(
1480 &mut self,
1481 session_id: SessionId,
1482 entity_id: EntityId,
1483 snapshot: &flatland_protocol::Snapshot,
1484 ) {
1485 self.clear_harvest_state();
1486 self.disconnect_reason = None;
1487 self.show_stats = false;
1488 self.show_craft_menu = false;
1489 self.show_shop_menu = false;
1490 self.shop_catalog = None;
1491 self.show_inventory_menu = false;
1492 self.session_id = session_id;
1493 self.entity_id = entity_id;
1494 self.connected = true;
1495 self.apply_snapshot_fields(snapshot, entity_id);
1496 if let Some(combat) = &snapshot.combat {
1497 self.apply_combat_hud(combat);
1498 let stacks = self.inventory_stacks.clone();
1499 self.sync_inventory_from_stacks(&stacks);
1500 }
1501 }
1502
1503 fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
1504 self.tick = delta.tick;
1505 self.world_clock = delta.world_clock;
1506
1507 if delta.entities.is_empty() {
1509 if let Some(combat) = &delta.combat {
1510 self.apply_combat_hud(combat);
1511 let stacks = self.inventory_stacks.clone();
1512 self.sync_inventory_from_stacks(&stacks);
1513 }
1514 return;
1515 }
1516 if !delta.buildings.is_empty() {
1517 self.buildings = delta.buildings.clone();
1518 }
1519 if !delta.blueprints.is_empty() {
1520 self.blueprints = delta.blueprints.clone();
1521 }
1522 self.sync_inventory_from_stacks(&delta.inventory);
1523
1524 if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
1525 self.player = Some(updated.clone());
1526 }
1527 self.entities = delta.entities.clone();
1528 if self.player.is_none() {
1529 self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
1530 }
1531
1532 if let Some(player) = self.player.as_ref() {
1533 let (px, py) = (player.transform.position.x, player.transform.position.y);
1534 let stale_inside = player.inside_building.is_some()
1535 && self.building_interior_at(px, py).is_none()
1536 && !is_instanced_world_coord(px, py, self.world_width_m, self.world_height_m);
1537 if stale_inside {
1538 self.player.as_mut().unwrap().inside_building = None;
1539 }
1540 }
1541
1542 if !delta.resource_nodes.is_empty() {
1545 self.resource_nodes = delta.resource_nodes.clone();
1546 }
1547 if self.player.as_ref().is_none_or(|p| p.inside_building.is_none()) {
1548 self.ground_drops = delta.ground_drops.clone();
1549 self.placed_containers = delta.placed_containers.clone();
1550 }
1551 if !delta.doors.is_empty() {
1552 self.doors = delta.doors.clone();
1553 }
1554 if !delta.npcs.is_empty() {
1555 self.npcs = delta.npcs.clone();
1556 }
1557 if let Some(combat) = &delta.combat {
1558 self.apply_combat_hud(combat);
1559 let stacks = self.inventory_stacks.clone();
1560 self.sync_inventory_from_stacks(&stacks);
1561 } else {
1562 self.refresh_inventory_ui();
1563 }
1564 }
1565
1566 pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
1568 let (px, py) = self.player_position();
1569 let mut out = Vec::new();
1570 for npc in &self.npcs {
1571 let Some(eid) = npc.entity_id else {
1572 continue;
1573 };
1574 let alive = npc
1575 .life_state
1576 .is_none_or(|s| s == LifeState::Alive);
1577 let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
1578 if alive && has_hp {
1579 out.push((eid, npc.label.clone()));
1580 }
1581 }
1582 out.sort_by(|(a_id, a_label), (b_id, b_label)| {
1583 let dist = |id: EntityId| {
1584 self.entities
1585 .iter()
1586 .find(|e| e.id == id)
1587 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
1588 .unwrap_or(f32::MAX)
1589 };
1590 dist(*a_id)
1591 .partial_cmp(&dist(*b_id))
1592 .unwrap_or(std::cmp::Ordering::Equal)
1593 .then_with(|| a_label.cmp(b_label))
1594 .then_with(|| a_id.cmp(b_id))
1595 });
1596 out
1597 }
1598
1599 pub fn refresh_combat_target_label(&mut self) {
1600 let Some(id) = self.combat_target else {
1601 return;
1602 };
1603 if let Some((_, label)) = self
1604 .combat_candidates()
1605 .into_iter()
1606 .find(|(eid, _)| *eid == id)
1607 {
1608 self.combat_target_label = Some(label);
1609 } else if let Some(label) = self.entities.iter().find(|e| e.id == id).map(|e| e.label.clone())
1610 {
1611 self.combat_target_label = Some(label);
1612 }
1613 }
1614
1615 pub fn nearest_interact_target(&self) -> Option<String> {
1617 let (px, py) = self.player_position();
1618 let inside = self.effective_inside_building();
1619
1620 #[derive(Clone, Copy, PartialEq, Eq)]
1621 enum Kind {
1622 Npc,
1623 ExitDoor,
1624 EnterDoor,
1625 Well,
1626 Water,
1627 }
1628
1629 fn kind_priority(kind: Kind) -> u8 {
1630 match kind {
1631 Kind::Npc => 0,
1632 Kind::ExitDoor => 1,
1633 Kind::EnterDoor => 2,
1634 Kind::Well => 3,
1635 Kind::Water => 4,
1636 }
1637 }
1638
1639 let mut best: Option<(f32, Kind, String)> = None;
1640
1641 let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
1642 if dist > max {
1643 return;
1644 }
1645 let replace = match best {
1646 None => true,
1647 Some((bd, _bk, _)) if dist < bd - 0.05 => true,
1648 Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
1649 kind_priority(kind) < kind_priority(bk)
1650 }
1651 _ => false,
1652 };
1653 if replace {
1654 best = Some((dist, kind, id));
1655 }
1656 };
1657
1658 for npc in &self.npcs {
1659 consider(
1660 distance(px, py, npc.x, npc.y),
1661 INTERACTION_RADIUS_M,
1662 Kind::Npc,
1663 npc.id.clone(),
1664 );
1665 }
1666
1667 for door in &self.doors {
1668 if inside.is_none() && door.is_exit {
1669 continue;
1670 }
1671 if let Some(ref bid) = inside {
1672 if door.building_id != *bid {
1673 continue;
1674 }
1675 }
1676 let max = if inside.is_some() && door.is_exit {
1677 INTERACTION_RADIUS_M
1678 } else {
1679 DOOR_INTERACTION_RADIUS_M
1680 };
1681 let kind = if door.is_exit {
1682 Kind::ExitDoor
1683 } else {
1684 Kind::EnterDoor
1685 };
1686 consider(
1687 distance(px, py, door.x, door.y),
1688 max,
1689 kind,
1690 door.id.clone(),
1691 );
1692 }
1693
1694 if inside.is_none() {
1695 for building in &self.buildings {
1696 if !building.tags.iter().any(|t| t == "well") {
1697 continue;
1698 }
1699 consider(
1700 distance(px, py, building.x, building.y),
1701 INTERACTION_RADIUS_M,
1702 Kind::Well,
1703 building.id.clone(),
1704 );
1705 }
1706 if self.in_shallow_water() {
1707 consider(0.0, INTERACTION_RADIUS_M, Kind::Water, "water_source".into());
1708 }
1709 }
1710
1711 best.map(|(_, _, id)| id)
1712 }
1713
1714 pub fn template_display_name(&self, template_id: &str) -> String {
1716 self.inventory_hints
1717 .get(template_id)
1718 .map(|h| h.display_name.clone())
1719 .filter(|n| !n.is_empty())
1720 .unwrap_or_else(|| humanize_template_id(template_id))
1721 }
1722
1723 pub fn placed_container_public_label(
1725 &self,
1726 c: &flatland_protocol::PlacedContainerView,
1727 ) -> String {
1728 let is_owner = match (self.character_id, c.owner_character_id) {
1729 (Some(me), Some(owner)) => me == owner,
1730 _ => false,
1731 };
1732 if is_owner {
1733 c.display_name.clone()
1734 } else {
1735 self.template_display_name(&c.template_id)
1736 }
1737 }
1738
1739 pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
1741 if stack.template_id != KEY_TEMPLATE {
1742 return None;
1743 }
1744 if let Some(name) = stack
1745 .props
1746 .get(PROP_OPENS_CONTAINER_NAME)
1747 .filter(|n| !n.is_empty())
1748 {
1749 return Some(name.clone());
1750 }
1751 let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
1752 self.container_name_for_lock_id(opens)
1753 }
1754
1755 pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
1757 if stack.template_id == KEY_TEMPLATE {
1758 self.template_display_name(KEY_TEMPLATE)
1759 } else {
1760 stack
1761 .display_name
1762 .clone()
1763 .unwrap_or_else(|| stack.template_id.clone())
1764 }
1765 }
1766
1767 pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
1769 if stack.template_id != KEY_TEMPLATE {
1770 return String::new();
1771 }
1772 match self.key_pair_chest_label(stack) {
1773 Some(chest) if self.key_drop_blocked(stack) => {
1774 format!(" [key for {chest} — can't drop while locked]")
1775 }
1776 Some(chest) => format!(" [key for {chest}]"),
1777 None => " [key — unpaired]".into(),
1778 }
1779 }
1780
1781 pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
1783 for c in &self.placed_containers {
1784 if c.lock_id.as_deref() == Some(lock) {
1785 return Some(c.display_name.clone());
1786 }
1787 }
1788 Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
1789 self.worn.values().find_map(|worn| {
1790 Self::container_name_in_stacks(std::slice::from_ref(worn), lock)
1791 })
1792 })
1793 }
1794
1795 pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
1797 if stack.template_id != KEY_TEMPLATE {
1798 return false;
1799 }
1800 let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
1801 return false;
1802 };
1803 for c in &self.placed_containers {
1804 if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
1805 return true;
1806 }
1807 }
1808 if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
1809 return true;
1810 }
1811 self.worn.values().any(|worn| {
1812 Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens)
1813 })
1814 }
1815
1816 fn container_name_in_stacks(
1817 stacks: &[flatland_protocol::ItemStack],
1818 lock: &str,
1819 ) -> Option<String> {
1820 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
1821 for s in stacks {
1822 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
1823 return Some(GameState::stack_container_label(s));
1824 }
1825 if let Some(name) = walk(&s.contents, lock) {
1826 return Some(name);
1827 }
1828 }
1829 None
1830 }
1831 walk(stacks, lock)
1832 }
1833
1834 fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
1835 stack
1836 .props
1837 .get(PROP_CUSTOM_NAME)
1838 .cloned()
1839 .or_else(|| stack.display_name.clone())
1840 .unwrap_or_else(|| stack.template_id.clone())
1841 }
1842
1843 fn has_locked_container_with_lock(
1844 stacks: &[flatland_protocol::ItemStack],
1845 lock: &str,
1846 ) -> bool {
1847 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
1848 for s in stacks {
1849 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
1850 return true;
1851 }
1852 if walk(&s.contents, lock) {
1853 return true;
1854 }
1855 }
1856 false
1857 }
1858 walk(stacks, lock)
1859 }
1860
1861 fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
1862 if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
1863 return Some(stack.clone());
1864 }
1865 for worn in self.worn.values() {
1866 if worn.item_instance_id == Some(instance_id) {
1867 return Some(worn.clone());
1868 }
1869 if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
1870 return Some(stack.clone());
1871 }
1872 }
1873 None
1874 }
1875
1876 pub fn location_context_lines(&self) -> Vec<ContextLine> {
1878 let (px, py) = self.player_position();
1879 let mut lines = Vec::new();
1880
1881 if let Some(kind) = self.terrain_at(px, py) {
1882 lines.push(ContextLine {
1883 on_top: true,
1884 text: format!("Terrain: {}", terrain_kind_label(kind)),
1885 });
1886 }
1887
1888 if let Some(building) = self.building_interior_at(px, py) {
1889 lines.push(ContextLine {
1890 on_top: true,
1891 text: format!("Inside: {}", building.label),
1892 });
1893 } else if let Some(id) = self.effective_inside_building() {
1894 if let Some(b) = self.buildings.iter().find(|b| b.id == id) {
1895 lines.push(ContextLine {
1896 on_top: false,
1897 text: format!("Near: {} (exterior)", b.label),
1898 });
1899 }
1900 }
1901
1902 let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
1903
1904 for node in &self.resource_nodes {
1905 let dist = distance(px, py, node.x, node.y);
1906 if dist > NEARBY_SCAN_M {
1907 continue;
1908 }
1909 let on_top = dist <= ON_TOP_RADIUS_M;
1910 let state = match node.state {
1911 flatland_protocol::ResourceNodeState::Available => " — press , to harvest",
1912 flatland_protocol::ResourceNodeState::Harvesting => " (being harvested)",
1913 flatland_protocol::ResourceNodeState::Cooldown => " (depleted)",
1914 };
1915 let prefix = if on_top { "On" } else { "Near" };
1916 nearby.push((
1917 dist,
1918 ContextLine {
1919 on_top,
1920 text: format!(
1921 "{prefix}: {} ({dist:.1}m){state}",
1922 node.label
1923 ),
1924 },
1925 ));
1926 }
1927
1928 for drop in &self.ground_drops {
1929 let dist = distance(px, py, drop.x, drop.y);
1930 if dist > INTERACTION_RADIUS_M {
1931 continue;
1932 }
1933 let on_top = dist <= ON_TOP_RADIUS_M;
1934 let name = self.template_display_name(&drop.template_id);
1935 let prefix = if on_top { "On" } else { "Near" };
1936 let qty = if drop.quantity > 1 {
1937 format!(" ×{}", drop.quantity)
1938 } else {
1939 String::new()
1940 };
1941 nearby.push((
1942 dist,
1943 ContextLine {
1944 on_top,
1945 text: format!(
1946 "{prefix}: {name}{qty} ({dist:.1}m) — p pickup"
1947 ),
1948 },
1949 ));
1950 }
1951
1952 for c in &self.placed_containers {
1953 let dist = distance(px, py, c.x, c.y);
1954 if dist > CONTAINER_RANGE_M {
1955 continue;
1956 }
1957 let on_top = dist <= ON_TOP_RADIUS_M;
1958 let name = self.placed_container_public_label(c);
1959 let lock = if c.locked { " [locked]" } else { "" };
1960 let prefix = if on_top { "On" } else { "Near" };
1961 nearby.push((
1962 dist,
1963 ContextLine {
1964 on_top,
1965 text: format!("{prefix}: {name}{lock} ({dist:.1}m)"),
1966 },
1967 ));
1968 }
1969
1970 for npc in &self.npcs {
1971 let dist = distance(px, py, npc.x, npc.y);
1972 if dist > NEARBY_SCAN_M {
1973 continue;
1974 }
1975 let on_top = dist <= ON_TOP_RADIUS_M;
1976 let prefix = if on_top { "On" } else { "Near" };
1977 nearby.push((
1978 dist,
1979 ContextLine {
1980 on_top,
1981 text: format!(
1982 "{prefix}: {} ({dist:.1}m) — f talk",
1983 npc.label
1984 ),
1985 },
1986 ));
1987 }
1988
1989 for door in &self.doors {
1990 let dist = distance(px, py, door.x, door.y);
1991 if dist > DOOR_INTERACTION_RADIUS_M {
1992 continue;
1993 }
1994 let building = self
1995 .buildings
1996 .iter()
1997 .find(|b| b.id == door.building_id)
1998 .map(|b| b.label.as_str())
1999 .unwrap_or(door.building_id.as_str());
2000 let action = if door.is_exit { "exit" } else { "enter" };
2001 nearby.push((
2002 dist,
2003 ContextLine {
2004 on_top: dist <= ON_TOP_RADIUS_M,
2005 text: format!("{building} door ({dist:.1}m) — f {action}"),
2006 },
2007 ));
2008 }
2009
2010 if self.in_shallow_water() {
2011 let already = self
2012 .terrain_at(px, py)
2013 .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
2014 if !already {
2015 nearby.push((
2016 0.0,
2017 ContextLine {
2018 on_top: true,
2019 text: "Shallow water — f fill bottle".into(),
2020 },
2021 ));
2022 } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
2023 line.text.push_str(" — f fill bottle");
2024 }
2025 }
2026
2027 for entity in &self.entities {
2028 if entity.id == self.entity_id {
2029 continue;
2030 }
2031 let dist = distance(px, py, entity.transform.position.x, entity.transform.position.y);
2032 if dist > NEARBY_SCAN_M {
2033 continue;
2034 }
2035 let label = if entity.label.is_empty() {
2036 format!("entity {}", entity.id)
2037 } else {
2038 entity.label.clone()
2039 };
2040 nearby.push((
2041 dist,
2042 ContextLine {
2043 on_top: dist <= ON_TOP_RADIUS_M,
2044 text: format!("Near: {label} ({dist:.1}m)"),
2045 },
2046 ));
2047 }
2048
2049 nearby.sort_by(|a, b| {
2050 a.0.partial_cmp(&b.0)
2051 .unwrap_or(std::cmp::Ordering::Equal)
2052 .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
2053 });
2054 lines.extend(nearby.into_iter().map(|(_, l)| l));
2055
2056 if lines.is_empty() {
2057 lines.push(ContextLine {
2058 on_top: false,
2059 text: "(nothing notable nearby)".into(),
2060 });
2061 }
2062
2063 lines
2064 }
2065}
2066
2067#[derive(Debug, Clone)]
2069pub struct ContextLine {
2070 pub on_top: bool,
2071 pub text: String,
2072}
2073
2074const ON_TOP_RADIUS_M: f32 = 0.65;
2075const NEARBY_SCAN_M: f32 = 5.0;
2076
2077fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
2078 use flatland_protocol::TerrainKindView;
2079 match kind {
2080 TerrainKindView::Grass => "Grass",
2081 TerrainKindView::Hill => "Hills",
2082 TerrainKindView::Bog => "Bog",
2083 TerrainKindView::ShallowWater => "Shallow water",
2084 TerrainKindView::DeepWater => "Deep water",
2085 }
2086}
2087
2088fn humanize_template_id(template_id: &str) -> String {
2089 template_id
2090 .split('_')
2091 .map(|word| {
2092 let mut chars = word.chars();
2093 match chars.next() {
2094 None => String::new(),
2095 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
2096 }
2097 })
2098 .collect::<Vec<_>>()
2099 .join(" ")
2100}
2101
2102fn point_in_building_interior(px: f32, py: f32, building: &BuildingView) -> bool {
2103 px >= building.interior_origin_x
2104 && px <= building.interior_origin_x + building.width_m
2105 && py >= building.interior_origin_y
2106 && py <= building.interior_origin_y + building.depth_m
2107}
2108
2109fn is_instanced_world_coord(px: f32, py: f32, world_w: f32, world_h: f32) -> bool {
2111 if world_w > 0.0 && world_h > 0.0 {
2112 px < 0.0 || py < 0.0 || px > world_w || py > world_h
2113 } else {
2114 px > 256.0 || py > 256.0
2115 }
2116}
2117
2118const HARVEST_RANGE_M: f32 = 1.5;
2120
2121pub struct GameClient<S: PlayConnection> {
2122 session: S,
2123 seq: Seq,
2124 pub state: GameState,
2125 last_move_forward: f32,
2126 last_move_strafe: f32,
2127}
2128
2129impl<S: PlayConnection> GameClient<S> {
2130 pub fn new(session: S) -> Self {
2131 let session_id = session.session_id();
2132 let entity_id = session.entity_id();
2133 Self {
2134 session,
2135 seq: 0,
2136 last_move_forward: 0.0,
2137 last_move_strafe: 0.0,
2138 state: GameState {
2139 session_id,
2140 entity_id,
2141 character_id: None,
2142 tick: 0,
2143 chunk_rev: 0,
2144 content_rev: 0,
2145 entities: Vec::new(),
2146 player: None,
2147 resource_nodes: Vec::new(),
2148 ground_drops: Vec::new(),
2149 placed_containers: Vec::new(),
2150 buildings: Vec::new(),
2151 doors: Vec::new(),
2152 npcs: Vec::new(),
2153 blueprints: Vec::new(),
2154 world_width_m: 0.0,
2155 world_height_m: 0.0,
2156 terrain_zones: Vec::new(),
2157 world_clock: flatland_protocol::WorldClock::default(),
2158 inventory: std::collections::HashMap::new(),
2159 inventory_hints: std::collections::HashMap::new(),
2160 logs: VecDeque::new(),
2161 intents_sent: 0,
2162 ticks_received: 0,
2163 connected: false,
2164 disconnect_reason: None,
2165 show_stats: false,
2166 show_craft_menu: false,
2167 craft_menu_index: 0,
2168 craft_batch_quantity: 1,
2169 show_shop_menu: false,
2170 shop_catalog: None,
2171 shop_tab: ShopTab::default(),
2172 shop_menu_index: 0,
2173 shop_quantity: 1,
2174 show_inventory_menu: false,
2175 inventory_menu_index: 0,
2176 show_move_picker: false,
2177 show_rename_prompt: false,
2178 rename_buffer: String::new(),
2179 move_picker_index: 0,
2180 move_picker: None,
2181 show_destroy_picker: false,
2182 destroy_confirm_pending: false,
2183 destroy_picker: None,
2184 combat_target: None,
2185 combat_target_label: None,
2186 in_combat: false,
2187 auto_attack: true,
2188 combat_has_los: false,
2189 attack_cd_ticks: 0,
2190 gcd_ticks: 0,
2191 weapon_ability_id: "unarmed".into(),
2192 mainhand_template_id: None,
2193 mainhand_label: None,
2194 worn: BTreeMap::new(),
2195 carry_mass: 0.0,
2196 carry_mass_max: 0.0,
2197 encumbrance: flatland_protocol::EncumbranceState::Light,
2198 inventory_stacks: Vec::new(),
2199 combat_target_detail: None,
2200 cast_progress: None,
2201 ability_cooldowns: Vec::new(),
2202 blocking_active: false,
2203 max_target_slots: 1,
2204 combat_slots: Vec::new(),
2205 rotation_presets: Vec::new(),
2206 show_loadout_menu: false,
2207 show_rotation_editor: false,
2208 loadout_menu_index: 0,
2209 rotation_editor: RotationEditorState::default(),
2210 harvest_in_progress: false,
2211 harvest_started_at: None,
2212 pending_craft_ack: None,
2213 },
2214 }
2215 }
2216
2217 pub fn entity_id(&self) -> EntityId {
2218 self.state.entity_id
2219 }
2220
2221 pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
2222 if self.state.connected {
2223 return Ok(());
2224 }
2225
2226 loop {
2227 match self.session.next_event().await {
2228 Some(SessionEvent::Welcome {
2229 session_id,
2230 entity_id,
2231 snapshot,
2232 }) => {
2233 self.state.restore_from_welcome(session_id, entity_id, &snapshot);
2234 self.state.push_log(format!(
2235 "Connected — session {session_id}, entity {entity_id}"
2236 ));
2237 return Ok(());
2238 }
2239 Some(SessionEvent::Disconnected { .. }) => {
2240 anyhow::bail!("disconnected before welcome");
2241 }
2242 Some(_) => continue,
2243 None => anyhow::bail!("session closed before welcome"),
2244 }
2245 }
2246 }
2247
2248 pub fn drain_events(&mut self) {
2250 while let Some(event) = self.session.try_next_event() {
2251 if self.handle_event_sync(event).is_err() {
2252 break;
2253 }
2254 }
2255 }
2256
2257 pub async fn next_event(&mut self) -> Option<SessionEvent> {
2259 self.session.next_event().await
2260 }
2261
2262 pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
2263 self.handle_event_sync(event)
2264 }
2265
2266 fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
2267 match event {
2268 SessionEvent::Welcome {
2269 session_id,
2270 entity_id,
2271 snapshot,
2272 } => {
2273 let resumed = self.state.connected;
2274 self.state
2275 .restore_from_welcome(session_id, entity_id, &snapshot);
2276 if resumed {
2277 self.state.push_log(format!(
2278 "Session restored — session {session_id}, entity {entity_id}"
2279 ));
2280 }
2281 }
2282 SessionEvent::ContentUpdated { snapshot } => {
2283 self.state.apply_snapshot_fields(&snapshot, self.state.entity_id);
2284 self.state.push_log(format!(
2285 "World updated (content rev {})",
2286 snapshot.content_rev
2287 ));
2288 }
2289 SessionEvent::Tick(delta) => {
2290 self.state.apply_tick_fields(&delta, self.state.entity_id);
2291 self.state.ticks_received += 1;
2292 }
2293 SessionEvent::IntentAck {
2294 entity_id,
2295 seq,
2296 tick,
2297 } => {
2298 crate::harvest_trace!(
2299 entity_id,
2300 seq,
2301 tick,
2302 "client received intent ack"
2303 );
2304 if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
2305 if *craft_seq == seq {
2306 let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
2307 if batches > 1 {
2308 self.state
2309 .push_log(format!("Crafting {label} ×{batches}…"));
2310 } else {
2311 self.state.push_log(format!("Crafting {label}…"));
2312 }
2313 }
2314 }
2315 }
2316 SessionEvent::Chat(msg) => {
2317 let label = match msg.channel {
2318 flatland_protocol::ChatChannel::Nearby => "nearby",
2319 flatland_protocol::ChatChannel::WhisperStone => "whisper",
2320 };
2321 self.state.push_log(format!(
2322 "[{label}] {}: {}",
2323 msg.from_name, msg.text
2324 ));
2325 }
2326 SessionEvent::HarvestResult(result) => {
2327 self.state.clear_harvest_state();
2328 crate::harvest_trace!(
2329 entity_id = self.state.entity_id,
2330 node_id = %result.node_id,
2331 template = %result.item_template,
2332 quantity = result.quantity,
2333 client_tick = self.state.tick,
2334 "client applied harvest result"
2335 );
2336 *self
2337 .state
2338 .inventory
2339 .entry(result.item_template.clone())
2340 .or_insert(0) += result.quantity;
2341 self.state.push_log(format!(
2342 "Harvested {} x{}",
2343 result.item_template, result.quantity
2344 ));
2345 }
2346 SessionEvent::CraftResult(result) => {
2347 for stack in &result.consumed {
2348 if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
2349 *qty = qty.saturating_sub(stack.quantity);
2350 if *qty == 0 {
2351 self.state.inventory.remove(&stack.template_id);
2352 }
2353 }
2354 }
2355 for stack in &result.outputs {
2356 *self
2357 .state
2358 .inventory
2359 .entry(stack.template_id.clone())
2360 .or_insert(0) += stack.quantity;
2361 }
2362 if let Some(output) = result.outputs.first() {
2363 if result.batch_total > 1 {
2364 self.state.push_log(format!(
2365 "Crafted {} x{} ({}/{})",
2366 output.template_id,
2367 output.quantity,
2368 result.batch_index,
2369 result.batch_total
2370 ));
2371 } else {
2372 self.state.push_log(format!(
2373 "Crafted {} x{}",
2374 output.template_id, output.quantity
2375 ));
2376 }
2377 } else {
2378 self.state.push_log(format!("Craft finished: {}", result.blueprint_id));
2379 }
2380 }
2381 SessionEvent::Death(notice) => {
2382 self.state.clear_harvest_state();
2383 self.state.push_log(notice.message.clone());
2384 self.state.push_log(format!(
2385 "Respawned at ({:.1}, {:.1})",
2386 notice.respawn_x, notice.respawn_y
2387 ));
2388 }
2389 SessionEvent::Interaction(notice) => {
2390 if notice.message.starts_with("Harvest failed:") {
2391 self.state.clear_harvest_state();
2392 }
2393 if notice.message.starts_with("Can't do that:") {
2394 self.state.pending_craft_ack = None;
2395 }
2396 if notice.message.starts_with("Cast failed:") {
2397 self.state.cast_progress = None;
2398 }
2399 if notice.message.contains("slain the") {
2400 self.state.combat_target = None;
2401 self.state.combat_target_label = None;
2402 }
2403 self.state.push_log(notice.message.clone());
2404 }
2405 SessionEvent::ShopOpened(catalog) => {
2406 self.state.apply_shop_catalog(catalog);
2407 }
2408 SessionEvent::UseResult(result) => {
2409 if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
2410 *qty = qty.saturating_sub(1);
2411 if *qty == 0 {
2412 self.state.inventory.remove(&result.template_id);
2413 }
2414 }
2415 self.state.push_log(format!(
2416 "Consumed {} (+{:.0} hunger, +{:.0} thirst)",
2417 result.template_id, result.hunger_restored, result.thirst_restored
2418 ));
2419 }
2420 SessionEvent::Disconnected { reason } => {
2421 self.state.clear_harvest_state();
2422 self.state.connected = false;
2423 self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
2424 if let Some(r) = &self.state.disconnect_reason {
2425 self.state.push_log(format!("Disconnected: {r}"));
2426 } else {
2427 self.state.push_log("Disconnected from server");
2428 }
2429 }
2430 }
2431 Ok(())
2432 }
2433
2434 pub fn is_connected(&self) -> bool {
2435 self.state.connected
2436 }
2437
2438 pub fn close_overlays(&mut self) {
2439 self.state.show_stats = false;
2440 self.state.show_craft_menu = false;
2441 self.state.show_shop_menu = false;
2442 self.state.shop_catalog = None;
2443 self.state.show_inventory_menu = false;
2444 self.state.show_loadout_menu = false;
2445 self.state.show_rotation_editor = false;
2446 self.state.rotation_editor.reset();
2447 self.state.show_rename_prompt = false;
2448 self.state.rename_buffer.clear();
2449 self.state.show_move_picker = false;
2450 self.state.move_picker = None;
2451 self.state.show_destroy_picker = false;
2452 self.state.destroy_confirm_pending = false;
2453 self.state.destroy_picker = None;
2454 }
2455
2456 pub fn back_on_esc(&mut self) -> bool {
2458 if self.state.show_rename_prompt {
2459 self.cancel_rename_prompt();
2460 return true;
2461 }
2462 if self.state.show_destroy_picker {
2463 if self.state.destroy_confirm_pending {
2464 self.cancel_destroy_confirm();
2465 } else {
2466 self.close_destroy_picker();
2467 }
2468 return true;
2469 }
2470 if self.state.show_move_picker {
2471 self.close_move_picker();
2472 return true;
2473 }
2474 if self.state.show_rotation_editor {
2475 match self.state.rotation_editor.mode {
2476 RotationEditorMode::List => {
2477 self.state.show_rotation_editor = false;
2478 self.state.rotation_editor.reset();
2479 }
2480 RotationEditorMode::EditLabel => {
2481 self.state.rotation_editor.label_buffer.clear();
2482 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
2483 }
2484 RotationEditorMode::PickAbility => {
2485 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
2486 }
2487 RotationEditorMode::EditSequence => {
2488 self.state.rotation_editor.draft = None;
2489 self.state.rotation_editor.mode = RotationEditorMode::List;
2490 }
2491 }
2492 return true;
2493 }
2494 if self.state.show_inventory_menu {
2495 self.close_inventory_menu();
2496 return true;
2497 }
2498 if self.state.show_craft_menu {
2499 self.close_craft_menu();
2500 return true;
2501 }
2502 if self.state.show_shop_menu {
2503 self.close_shop_menu();
2504 return true;
2505 }
2506 if self.state.show_loadout_menu {
2507 self.state.show_loadout_menu = false;
2508 return true;
2509 }
2510 if self.state.show_stats {
2511 self.state.show_stats = false;
2512 return true;
2513 }
2514 false
2515 }
2516
2517 pub fn toggle_stats(&mut self) {
2518 self.state.show_stats = !self.state.show_stats;
2519 if self.state.show_stats {
2520 self.state.show_craft_menu = false;
2521 self.state.show_shop_menu = false;
2522 self.state.shop_catalog = None;
2523 self.state.show_inventory_menu = false;
2524 }
2525 }
2526
2527 pub fn open_inventory_menu(&mut self) {
2528 self.state.show_inventory_menu = true;
2529 self.state.show_craft_menu = false;
2530 self.state.show_shop_menu = false;
2531 self.state.shop_catalog = None;
2532 self.state.show_stats = false;
2533 self.state.show_move_picker = false;
2534 self.state.move_picker = None;
2535 self.state.show_destroy_picker = false;
2536 self.state.destroy_confirm_pending = false;
2537 self.state.destroy_picker = None;
2538 self.state.show_rename_prompt = false;
2539 self.state.rename_buffer.clear();
2540 self.state.clamp_inventory_indices();
2541 }
2542
2543 pub fn close_inventory_menu(&mut self) {
2544 self.state.show_inventory_menu = false;
2545 self.state.show_move_picker = false;
2546 self.state.move_picker = None;
2547 self.state.show_destroy_picker = false;
2548 self.state.destroy_confirm_pending = false;
2549 self.state.destroy_picker = None;
2550 self.state.show_rename_prompt = false;
2551 self.state.rename_buffer.clear();
2552 }
2553
2554 pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
2555 let Some(row) = self.state.inventory_selected_row() else {
2556 anyhow::bail!("inventory empty");
2557 };
2558 if !self.state.row_is_renameable_container(&row) {
2559 anyhow::bail!("only storage containers can be renamed");
2560 }
2561 let current = row
2562 .stack
2563 .display_name
2564 .clone()
2565 .unwrap_or_else(|| row.stack.template_id.clone());
2566 self.state.rename_buffer = current;
2567 self.state.show_rename_prompt = true;
2568 self.state.show_move_picker = false;
2569 self.state.show_destroy_picker = false;
2570 self.state.destroy_confirm_pending = false;
2571 Ok(())
2572 }
2573
2574 pub fn cancel_rename_prompt(&mut self) {
2575 self.state.show_rename_prompt = false;
2576 self.state.rename_buffer.clear();
2577 }
2578
2579 pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
2580 let name = self.state.rename_buffer.trim().to_string();
2581 if name.is_empty() {
2582 anyhow::bail!("name cannot be empty");
2583 }
2584 let Some(row) = self.state.inventory_selected_row() else {
2585 anyhow::bail!("inventory empty");
2586 };
2587 let Some(instance_id) = row.stack.item_instance_id else {
2588 anyhow::bail!("item has no instance id");
2589 };
2590 self.seq += 1;
2591 self.session
2592 .submit_intent(Intent::RenameContainer {
2593 entity_id: self.state.entity_id,
2594 item_instance_id: instance_id,
2595 location: row.from.clone(),
2596 name,
2597 seq: self.seq,
2598 })
2599 .await?;
2600 self.state.intents_sent += 1;
2601 self.state.show_rename_prompt = false;
2602 self.state.rename_buffer.clear();
2603 Ok(())
2604 }
2605
2606 pub fn toggle_inventory_menu(&mut self) {
2607 if self.state.show_inventory_menu {
2608 self.close_inventory_menu();
2609 } else {
2610 self.open_inventory_menu();
2611 }
2612 }
2613
2614 pub fn inventory_menu_move(&mut self, delta: i32) {
2616 if self.state.show_move_picker {
2617 let n = self
2618 .state
2619 .move_picker
2620 .as_ref()
2621 .map(|p| p.options.len())
2622 .unwrap_or(0);
2623 if n == 0 {
2624 return;
2625 }
2626 let idx = self.state.move_picker_index as i32;
2627 self.state.move_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
2628 self.state.clamp_move_picker_quantity();
2629 return;
2630 }
2631 let n = self.state.inventory_selectable_rows().len();
2632 if n == 0 {
2633 return;
2634 }
2635 let idx = self.state.inventory_menu_index as i32;
2636 self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
2637 }
2638
2639 pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
2644 if self.state.show_destroy_picker {
2645 if self.state.destroy_confirm_pending {
2646 return self.confirm_destroy_item().await;
2647 }
2648 return self.request_destroy_confirm();
2649 }
2650 if self.state.show_move_picker {
2651 return self.confirm_move_picker().await;
2652 }
2653 let Some(row) = self.state.inventory_selected_row() else {
2654 anyhow::bail!("inventory empty");
2655 };
2656 if row.is_equip_shell {
2657 let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
2658 anyhow::bail!("not a worn item");
2659 };
2660 return self.equip_worn(slot, None).await;
2661 }
2662 if row.is_chest_shell {
2663 let flatland_protocol::InventoryLocation::Placed { container_id } = row.from else {
2664 anyhow::bail!("not a placed chest");
2665 };
2666 return self.toggle_placed_chest_lock(&container_id).await;
2667 }
2668 let template_id = row.stack.template_id.clone();
2669 let instance_id = row.stack.item_instance_id;
2670 let category = self.state.inventory_item_category(&template_id);
2671 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
2672
2673 if category == Some("weapon") {
2674 return self.equip_mainhand(Some(template_id)).await;
2675 }
2676 if (category == Some("container") || category == Some("armor")) && on_person {
2677 if let Some(inst) = instance_id {
2678 if template_id.contains("chest") {
2679 return self.place_container(inst).await;
2680 }
2681 if let Some(slot) = guess_body_slot(&template_id) {
2685 return self.equip_worn(slot, Some(inst)).await;
2686 }
2687 }
2688 }
2689 if category == Some("consumable") && on_person {
2690 return self.use_item(&template_id).await;
2691 }
2692 self.open_move_picker()
2696 }
2697
2698 pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
2701 let Some(row) = self.state.inventory_selected_row() else {
2702 anyhow::bail!("inventory empty");
2703 };
2704 if row.is_equip_shell {
2705 anyhow::bail!("this is a worn bag — press Enter to unequip it");
2706 }
2707 if row.is_chest_shell {
2708 anyhow::bail!("select the chest and press Enter or l to lock/unlock it");
2709 }
2710 let Some(instance_id) = row.stack.item_instance_id else {
2711 anyhow::bail!("item has no instance id");
2712 };
2713 let options = self.state.move_destinations_for(
2714 &row.from,
2715 row.from_parent_instance_id,
2716 row.stack.item_instance_id,
2717 &row.stack.template_id,
2718 );
2719 let item_label = row
2720 .stack
2721 .display_name
2722 .clone()
2723 .unwrap_or_else(|| row.stack.template_id.clone());
2724 self.state.move_picker = Some(MovePicker {
2725 item_instance_id: instance_id,
2726 from: row.from,
2727 item_label,
2728 template_id: row.stack.template_id.clone(),
2729 stack_quantity: row.stack.quantity,
2730 quantity: row.stack.quantity,
2731 options,
2732 });
2733 self.state.move_picker_index = 0;
2734 self.state.show_move_picker = true;
2735 self.state.show_destroy_picker = false;
2736 self.state.destroy_confirm_pending = false;
2737 self.state.destroy_picker = None;
2738 Ok(())
2739 }
2740
2741 pub fn close_move_picker(&mut self) {
2742 self.state.show_move_picker = false;
2743 self.state.move_picker = None;
2744 }
2745
2746 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
2747 self.state.move_picker_adjust_quantity(delta);
2748 }
2749
2750 pub fn move_picker_set_quantity_max(&mut self) {
2751 self.state.move_picker_set_quantity_max();
2752 }
2753
2754 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
2755 self.state.destroy_picker_adjust_quantity(delta);
2756 }
2757
2758 pub fn destroy_picker_set_quantity_max(&mut self) {
2759 self.state.destroy_picker_set_quantity_max();
2760 }
2761
2762 async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
2763 let Some(picker) = self.state.move_picker.clone() else {
2764 self.close_move_picker();
2765 return Ok(());
2766 };
2767 let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
2768 self.close_move_picker();
2769 return Ok(());
2770 };
2771 match option.kind {
2772 MoveOptionKind::Cancel => {
2773 self.close_move_picker();
2774 }
2775 MoveOptionKind::Drop => {
2776 self.close_move_picker();
2777 if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
2778 if self.state.key_drop_blocked(&stack) {
2779 anyhow::bail!("cannot drop the key while its chest is locked");
2780 }
2781 }
2782 self.drop_item(picker.item_instance_id, picker.from).await?;
2783 self.state.push_log(format!("Dropped {}", picker.item_label));
2784 }
2785 MoveOptionKind::Move {
2786 location,
2787 parent_instance_id,
2788 } => {
2789 self.close_move_picker();
2790 let qty = if picker.quantity >= picker.stack_quantity {
2791 None
2792 } else {
2793 Some(picker.quantity)
2794 };
2795 self.move_item(
2796 picker.item_instance_id,
2797 picker.from,
2798 location,
2799 parent_instance_id,
2800 qty,
2801 )
2802 .await?;
2803 let moved = qty.unwrap_or(picker.stack_quantity);
2804 if moved >= picker.stack_quantity {
2805 self.state.push_log(format!("Moved {}", picker.item_label));
2806 } else {
2807 self.state.push_log(format!(
2808 "Moved {} ×{} of {}",
2809 picker.item_label, moved, picker.stack_quantity
2810 ));
2811 }
2812 }
2813 }
2814 Ok(())
2815 }
2816
2817 pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
2819 let Some(row) = self.state.inventory_selected_row() else {
2820 anyhow::bail!("inventory empty");
2821 };
2822 if row.is_equip_shell {
2823 anyhow::bail!("unequip the bag first (Enter), then drop from your person");
2824 }
2825 if row.is_chest_shell {
2826 anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
2827 }
2828 let Some(inst) = row.stack.item_instance_id else {
2829 anyhow::bail!("item has no instance id");
2830 };
2831 if self.state.key_drop_blocked(&row.stack) {
2832 anyhow::bail!("cannot drop the key while its chest is locked");
2833 }
2834 let label = row
2835 .stack
2836 .display_name
2837 .clone()
2838 .unwrap_or_else(|| row.stack.template_id.clone());
2839 self.drop_item(inst, row.from).await?;
2840 self.state.push_log(format!("Dropped {label}"));
2841 Ok(())
2842 }
2843
2844 pub async fn drop_item(
2845 &mut self,
2846 item_instance_id: uuid::Uuid,
2847 from: flatland_protocol::InventoryLocation,
2848 ) -> anyhow::Result<()> {
2849 self.seq += 1;
2850 self.session
2851 .submit_intent(Intent::DropItem {
2852 entity_id: self.state.entity_id,
2853 item_instance_id,
2854 from,
2855 seq: self.seq,
2856 })
2857 .await?;
2858 self.state.intents_sent += 1;
2859 Ok(())
2860 }
2861
2862 pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
2864 let Some(row) = self.state.inventory_selected_row() else {
2865 anyhow::bail!("inventory empty");
2866 };
2867 if row.is_equip_shell {
2868 anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
2869 }
2870 if row.is_chest_shell {
2871 anyhow::bail!("can't destroy a placed chest from the inventory list");
2872 }
2873 let Some(instance_id) = row.stack.item_instance_id else {
2874 anyhow::bail!("item has no instance id");
2875 };
2876 if self.state.key_drop_blocked(&row.stack) {
2877 anyhow::bail!("cannot destroy the key while its chest is locked");
2878 }
2879 let item_label = row
2880 .stack
2881 .display_name
2882 .clone()
2883 .unwrap_or_else(|| row.stack.template_id.clone());
2884 self.state.destroy_picker = Some(DestroyPicker {
2885 item_instance_id: instance_id,
2886 from: row.from,
2887 item_label,
2888 stack_quantity: row.stack.quantity,
2889 quantity: row.stack.quantity,
2890 });
2891 self.state.destroy_confirm_pending = false;
2892 self.state.show_destroy_picker = true;
2893 self.state.show_move_picker = false;
2894 self.state.move_picker = None;
2895 Ok(())
2896 }
2897
2898 pub fn close_destroy_picker(&mut self) {
2899 self.state.show_destroy_picker = false;
2900 self.state.destroy_confirm_pending = false;
2901 self.state.destroy_picker = None;
2902 }
2903
2904 pub fn cancel_destroy_confirm(&mut self) {
2905 self.state.destroy_confirm_pending = false;
2906 }
2907
2908 pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
2909 if self.state.destroy_picker.is_none() {
2910 self.close_destroy_picker();
2911 return Ok(());
2912 }
2913 self.state.destroy_confirm_pending = true;
2914 Ok(())
2915 }
2916
2917 pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
2918 let Some(picker) = self.state.destroy_picker.clone() else {
2919 self.close_destroy_picker();
2920 return Ok(());
2921 };
2922 let qty = if picker.quantity >= picker.stack_quantity {
2923 None
2924 } else {
2925 Some(picker.quantity)
2926 };
2927 self.destroy_item(picker.item_instance_id, picker.from, qty)
2928 .await?;
2929 let destroyed = qty.unwrap_or(picker.stack_quantity);
2930 if destroyed >= picker.stack_quantity {
2931 self.state
2932 .push_log(format!("Destroyed {}", picker.item_label));
2933 } else {
2934 self.state.push_log(format!(
2935 "Destroyed {} ×{} of {}",
2936 picker.item_label, destroyed, picker.stack_quantity
2937 ));
2938 }
2939 self.close_destroy_picker();
2940 Ok(())
2941 }
2942
2943 pub async fn destroy_item(
2944 &mut self,
2945 item_instance_id: uuid::Uuid,
2946 from: flatland_protocol::InventoryLocation,
2947 quantity: Option<u32>,
2948 ) -> anyhow::Result<()> {
2949 self.seq += 1;
2950 self.session
2951 .submit_intent(Intent::DestroyItem {
2952 entity_id: self.state.entity_id,
2953 item_instance_id,
2954 from,
2955 quantity,
2956 seq: self.seq,
2957 })
2958 .await?;
2959 self.state.intents_sent += 1;
2960 Ok(())
2961 }
2962
2963 pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
2965 if let Some(row) = self.state.inventory_selected_row() {
2966 if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
2967 return self.toggle_placed_chest_lock(container_id).await;
2968 }
2969 }
2970 self.toggle_nearby_chest_lock().await
2971 }
2972
2973 pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
2974 let chest = self
2975 .state
2976 .placed_containers
2977 .iter()
2978 .find(|c| c.id == container_id)
2979 .cloned()
2980 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
2981 let (px, py) = self.state.player_position();
2982 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
2983 anyhow::bail!("too far from {}", chest.display_name);
2984 }
2985 if !chest.accessible && chest.locked {
2986 anyhow::bail!(
2987 "need the matching key for {} (each crafted chest has its own key)",
2988 chest.display_name
2989 );
2990 }
2991 let lock = !chest.locked;
2992 self.set_container_locked(
2993 flatland_protocol::InventoryLocation::Placed {
2994 container_id: chest.id.clone(),
2995 },
2996 lock,
2997 )
2998 .await?;
2999 self.state.push_log(if lock {
3000 format!("Locked {}", chest.display_name)
3001 } else {
3002 format!("Unlocked {}", chest.display_name)
3003 });
3004 Ok(())
3005 }
3006
3007 pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
3009 let chest = self
3010 .state
3011 .nearest_placed_container(CONTAINER_RANGE_M)
3012 .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
3013 self.toggle_placed_chest_lock(&chest.id).await
3014 }
3015
3016 pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
3017 self.equip_mainhand(None).await
3018 }
3019
3020 pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
3021 let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
3022 for slot in slots {
3023 self.equip_worn(slot, None).await?;
3024 }
3025 Ok(())
3026 }
3027
3028 pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
3029 let (px, py) = self.state.player_position();
3030 let nearest = self
3031 .state
3032 .placed_containers
3033 .iter()
3034 .min_by(|a, b| {
3035 let da = (a.x - px).hypot(a.y - py);
3036 let db = (b.x - px).hypot(b.y - py);
3037 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
3038 })
3039 .cloned();
3040 let Some(chest) = nearest else {
3041 anyhow::bail!("no chest nearby");
3042 };
3043 if (chest.x - px).hypot(chest.y - py) > 2.0 {
3044 anyhow::bail!("too far from chest");
3045 }
3046 self.pickup_container(chest.id).await
3047 }
3048
3049 pub async fn equip_worn(
3050 &mut self,
3051 slot: BodySlot,
3052 instance_id: Option<uuid::Uuid>,
3053 ) -> anyhow::Result<()> {
3054 self.seq += 1;
3055 self.session
3056 .submit_intent(Intent::EquipWorn {
3057 entity_id: self.state.entity_id,
3058 slot,
3059 instance_id,
3060 seq: self.seq,
3061 })
3062 .await?;
3063 self.state.intents_sent += 1;
3064 Ok(())
3065 }
3066
3067 pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
3068 self.seq += 1;
3069 self.session
3070 .submit_intent(Intent::PlaceContainer {
3071 entity_id: self.state.entity_id,
3072 item_instance_id,
3073 seq: self.seq,
3074 })
3075 .await?;
3076 self.state.intents_sent += 1;
3077 Ok(())
3078 }
3079
3080 pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
3081 self.seq += 1;
3082 self.session
3083 .submit_intent(Intent::PickupContainer {
3084 entity_id: self.state.entity_id,
3085 container_id,
3086 seq: self.seq,
3087 })
3088 .await?;
3089 self.state.intents_sent += 1;
3090 Ok(())
3091 }
3092
3093 pub async fn move_item(
3094 &mut self,
3095 item_instance_id: uuid::Uuid,
3096 from: flatland_protocol::InventoryLocation,
3097 to: flatland_protocol::InventoryLocation,
3098 to_parent_instance_id: Option<uuid::Uuid>,
3099 quantity: Option<u32>,
3100 ) -> anyhow::Result<()> {
3101 self.seq += 1;
3102 self.session
3103 .submit_intent(Intent::MoveItem {
3104 entity_id: self.state.entity_id,
3105 item_instance_id,
3106 from,
3107 to,
3108 to_parent_instance_id,
3109 quantity,
3110 seq: self.seq,
3111 })
3112 .await?;
3113 self.state.intents_sent += 1;
3114 Ok(())
3115 }
3116
3117 pub async fn set_container_locked(
3118 &mut self,
3119 location: flatland_protocol::InventoryLocation,
3120 locked: bool,
3121 ) -> anyhow::Result<()> {
3122 self.seq += 1;
3123 self.session
3124 .submit_intent(Intent::SetContainerLocked {
3125 entity_id: self.state.entity_id,
3126 location,
3127 locked,
3128 seq: self.seq,
3129 })
3130 .await?;
3131 self.state.intents_sent += 1;
3132 Ok(())
3133 }
3134
3135 pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
3136 if !self.state.is_alive() {
3137 anyhow::bail!("you are dead");
3138 }
3139 self.seq += 1;
3140 self.session
3141 .submit_intent(Intent::Use {
3142 entity_id: self.state.entity_id,
3143 template_id: template_id.to_string(),
3144 seq: self.seq,
3145 })
3146 .await?;
3147 self.state.intents_sent += 1;
3148 Ok(())
3149 }
3150
3151 pub fn open_craft_menu(&mut self) {
3152 self.state.show_craft_menu = true;
3153 self.state.show_shop_menu = false;
3154 self.state.shop_catalog = None;
3155 self.state.show_stats = false;
3156 self.state.show_inventory_menu = false;
3157 if self.state.blueprints.is_empty() {
3158 self.state.craft_menu_index = 0;
3159 self.state.craft_batch_quantity = 1;
3160 return;
3161 }
3162 self.state.craft_menu_index = self
3163 .state
3164 .craft_menu_index
3165 .min(self.state.blueprints.len() - 1);
3166 if let Some(idx) = self
3167 .state
3168 .blueprints
3169 .iter()
3170 .position(|bp| self.state.can_craft_blueprint(bp))
3171 {
3172 self.state.craft_menu_index = idx;
3173 }
3174 self.state.clamp_craft_batch_quantity();
3175 }
3176
3177 pub fn close_craft_menu(&mut self) {
3178 self.state.show_craft_menu = false;
3179 }
3180
3181 pub fn close_shop_menu(&mut self) {
3182 self.state.show_shop_menu = false;
3183 self.state.shop_catalog = None;
3184 }
3185
3186 pub fn shop_tab_toggle(&mut self) {
3187 self.state.shop_tab = match self.state.shop_tab {
3188 ShopTab::Buy => ShopTab::Sell,
3189 ShopTab::Sell => ShopTab::Buy,
3190 };
3191 self.state.shop_menu_index = 0;
3192 self.state.clamp_shop_selection();
3193 }
3194
3195 pub fn shop_menu_move(&mut self, delta: i32) {
3196 self.state.shop_menu_move(delta);
3197 }
3198
3199 pub fn shop_quantity_adjust(&mut self, delta: i32) {
3200 self.state.shop_quantity_adjust(delta);
3201 }
3202
3203 pub fn shop_quantity_set_max(&mut self) {
3204 self.state.shop_quantity_set_max();
3205 }
3206
3207 pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
3208 if !self.state.is_alive() {
3209 anyhow::bail!("you are dead");
3210 }
3211 let Some(catalog) = self.state.shop_catalog.clone() else {
3212 anyhow::bail!("no shop open");
3213 };
3214 self.seq += 1;
3215 let seq = self.seq;
3216 match self.state.shop_tab {
3217 ShopTab::Buy => {
3218 let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
3219 anyhow::bail!("nothing selected");
3220 };
3221 if offer.already_owned {
3222 anyhow::bail!("already owned");
3223 }
3224 self.session
3225 .submit_intent(Intent::ShopBuy {
3226 entity_id: self.state.entity_id,
3227 npc_id: catalog.npc_id.clone(),
3228 offer_id: offer.offer_id.clone(),
3229 quantity: self.state.shop_quantity,
3230 seq,
3231 })
3232 .await?;
3233 }
3234 ShopTab::Sell => {
3235 let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
3236 anyhow::bail!("nothing to sell");
3237 };
3238 self.session
3239 .submit_intent(Intent::ShopSell {
3240 entity_id: self.state.entity_id,
3241 npc_id: catalog.npc_id.clone(),
3242 template_id: line.template_id.clone(),
3243 quantity: self.state.shop_quantity.min(line.quantity),
3244 seq,
3245 })
3246 .await?;
3247 }
3248 }
3249 self.state.intents_sent += 1;
3250 Ok(())
3251 }
3252
3253 pub fn craft_menu_move(&mut self, delta: i32) {
3254 let n = self.state.blueprints.len();
3255 if n == 0 {
3256 return;
3257 }
3258 let idx = self.state.craft_menu_index as i32;
3259 let next = (idx + delta).rem_euclid(n as i32);
3260 self.state.craft_menu_index = next as usize;
3261 self.state.clamp_craft_batch_quantity();
3262 }
3263
3264 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
3265 self.state.craft_batch_adjust_quantity(delta);
3266 }
3267
3268 pub fn craft_batch_set_max(&mut self) {
3269 self.state.craft_batch_set_max();
3270 }
3271
3272 pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
3273 let Some(blueprint) = self
3274 .state
3275 .blueprints
3276 .get(self.state.craft_menu_index)
3277 .cloned()
3278 else {
3279 anyhow::bail!("no blueprints known");
3280 };
3281 if !self.state.can_craft_blueprint(&blueprint) {
3282 let hint = self
3283 .state
3284 .craft_missing_hint(&blueprint)
3285 .unwrap_or_else(|| "missing materials".into());
3286 anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
3287 }
3288 let count = self.state.craft_batch_quantity;
3289 let max = self.state.max_craft_batches(&blueprint);
3290 if max == 0 {
3291 anyhow::bail!("cannot craft {}", blueprint.label);
3292 }
3293 let batches = count.min(max);
3294 self.craft(&blueprint.id, Some(batches)).await?;
3295 self.state.show_craft_menu = false;
3296 Ok(())
3297 }
3298
3299 pub async fn move_by(
3300 &mut self,
3301 forward: f32,
3302 strafe: f32,
3303 vertical: f32,
3304 sprint: bool,
3305 ) -> anyhow::Result<()> {
3306 if !self.state.is_alive() {
3307 anyhow::bail!("you are dead");
3308 }
3309 if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
3310 self.last_move_forward = forward;
3311 self.last_move_strafe = strafe;
3312 }
3313 self.seq += 1;
3314 self.session
3315 .submit_intent(Intent::Move {
3316 entity_id: self.state.entity_id,
3317 forward,
3318 strafe,
3319 vertical,
3320 sprint,
3321 seq: self.seq,
3322 })
3323 .await?;
3324 self.state.intents_sent += 1;
3325 Ok(())
3326 }
3327
3328 pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
3329 if !self.state.connected {
3330 crate::harvest_trace!("harvest_nearest rejected: not connected");
3331 anyhow::bail!("not connected");
3332 }
3333 if !self.state.is_alive() {
3334 crate::harvest_trace!("harvest_nearest rejected: player dead");
3335 anyhow::bail!("you are dead");
3336 }
3337 if self.state.harvest_in_progress {
3338 if self.state.harvest_state_stale() {
3339 self.state.clear_harvest_state();
3340 } else {
3341 anyhow::bail!("already harvesting");
3342 }
3343 }
3344 let (px, py) = self
3345 .state
3346 .player
3347 .as_ref()
3348 .map(|p| (p.transform.position.x, p.transform.position.y))
3349 .unwrap_or((0.0, 0.0));
3350
3351 let available = self
3352 .state
3353 .resource_nodes
3354 .iter()
3355 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
3356 .count();
3357 let node_id = self
3358 .state
3359 .resource_nodes
3360 .iter()
3361 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
3362 .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
3363 .min_by(|a, b| {
3364 let da = distance(px, py, a.x, a.y);
3365 let db = distance(px, py, b.x, b.y);
3366 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
3367 })
3368 .map(|n| n.id.clone());
3369
3370 let Some(node_id) = node_id else {
3371 let has_loot = self.state.ground_drops.iter().any(|d| {
3372 distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M
3373 });
3374 if has_loot {
3375 return self.pickup_nearest().await;
3376 }
3377 anyhow::bail!(
3378 "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press p to pick up"
3379 );
3380 };
3381
3382 self.seq += 1;
3383 let seq = self.seq;
3384 crate::harvest_trace!(
3385 entity_id = self.state.entity_id,
3386 node_id = %node_id,
3387 seq,
3388 px,
3389 py,
3390 available_nodes = available,
3391 "submitting harvest intent"
3392 );
3393 self.session
3394 .submit_intent(Intent::Harvest {
3395 entity_id: self.state.entity_id,
3396 node_id,
3397 seq,
3398 })
3399 .await?;
3400 self.state.intents_sent += 1;
3401 self.state.harvest_in_progress = true;
3402 self.state.harvest_started_at = Some(Instant::now());
3403 self.state.push_log("Harvesting…");
3404 crate::harvest_trace!(entity_id = self.state.entity_id, seq, "harvest intent queued to session");
3405 Ok(())
3406 }
3407
3408 pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
3409 if !self.state.is_alive() {
3410 anyhow::bail!("you are dead");
3411 }
3412 let blueprint_id = self
3413 .state
3414 .blueprints
3415 .iter()
3416 .find(|bp| self.state.can_craft_blueprint(bp))
3417 .map(|bp| bp.id.clone())
3418 .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
3419 self.craft(&blueprint_id, None).await
3420 }
3421
3422 pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
3423 if !self.state.is_alive() {
3424 anyhow::bail!("you are dead");
3425 }
3426 self.seq += 1;
3427 self.session
3428 .submit_intent(Intent::Craft {
3429 entity_id: self.state.entity_id,
3430 blueprint_id: blueprint_id.to_string(),
3431 count,
3432 seq: self.seq,
3433 })
3434 .await?;
3435 self.state.intents_sent += 1;
3436 let (label, batches) = self
3437 .state
3438 .blueprints
3439 .iter()
3440 .find(|b| b.id == blueprint_id)
3441 .map(|b| {
3442 let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
3443 (b.label.as_str(), n)
3444 })
3445 .unwrap_or((blueprint_id, count.unwrap_or(1)));
3446 self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
3447 Ok(())
3448 }
3449
3450 pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
3451 if !self.state.is_alive() {
3452 anyhow::bail!("you are dead");
3453 }
3454 let target_id = match self.state.nearest_interact_target() {
3455 Some(id) => id,
3456 None => {
3457 let (px, py) = self.state.player_position();
3458 let has_loot = self.state.ground_drops.iter().any(|d| {
3459 distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M
3460 });
3461 if has_loot {
3462 return self.pickup_nearest().await;
3463 }
3464 anyhow::bail!("nothing to interact with nearby (stand on * loot and press p)");
3465 }
3466 };
3467 self.seq += 1;
3468 self.session
3469 .submit_intent(Intent::Interact {
3470 entity_id: self.state.entity_id,
3471 target_id: target_id.clone(),
3472 seq: self.seq,
3473 })
3474 .await?;
3475 self.state.intents_sent += 1;
3476 Ok(())
3477 }
3478
3479 pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
3480 self.seq += 1;
3481 self.session
3482 .submit_intent(Intent::TestDamage {
3483 entity_id: self.state.entity_id,
3484 amount,
3485 seq: self.seq,
3486 })
3487 .await?;
3488 self.state.intents_sent += 1;
3489 Ok(())
3490 }
3491
3492 pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
3493 self.cycle_combat_target_slot(1, reverse).await
3494 }
3495
3496 pub async fn cycle_combat_target_slot(
3497 &mut self,
3498 slot_index: u8,
3499 reverse: bool,
3500 ) -> anyhow::Result<()> {
3501 if !self.state.is_alive() {
3502 anyhow::bail!("you are dead");
3503 }
3504 let candidates = self.state.candidates_for_slot(slot_index);
3505 if candidates.is_empty() {
3506 anyhow::bail!("no targets nearby");
3507 }
3508 let current = self.state.target_for_slot(slot_index);
3509 let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
3510 let next_idx = match idx {
3511 None => 0,
3512 Some(i) if reverse => {
3513 if i == 0 {
3514 candidates.len() - 1
3515 } else {
3516 i - 1
3517 }
3518 }
3519 Some(i) => (i + 1) % candidates.len(),
3520 };
3521 if idx == Some(next_idx) && candidates.len() == 1 {
3522 self.clear_combat_target_slot(slot_index).await?;
3523 return Ok(());
3524 }
3525 let (target_id, label) = candidates[next_idx].clone();
3526 self.set_combat_target_slot(slot_index, target_id, &label)
3527 .await
3528 }
3529
3530 pub async fn set_combat_target_slot(
3531 &mut self,
3532 slot_index: u8,
3533 target_id: EntityId,
3534 label: &str,
3535 ) -> anyhow::Result<()> {
3536 if !self.state.is_alive() {
3537 anyhow::bail!("you are dead");
3538 }
3539 self.seq += 1;
3540 self.session
3541 .submit_intent(Intent::SetTargetSlot {
3542 entity_id: self.state.entity_id,
3543 slot_index,
3544 target_id,
3545 seq: self.seq,
3546 })
3547 .await?;
3548 self.state.intents_sent += 1;
3549 if slot_index == 1 {
3550 self.state.combat_target = Some(target_id);
3551 self.state.combat_target_label = Some(label.to_string());
3552 }
3553 self.state
3554 .push_log(format!("Slot {slot_index} target: {label}"));
3555 Ok(())
3556 }
3557
3558 pub async fn set_combat_target(
3559 &mut self,
3560 target_id: EntityId,
3561 label: &str,
3562 ) -> anyhow::Result<()> {
3563 self.set_combat_target_slot(1, target_id, label).await
3564 }
3565
3566 pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
3567 if slot_index == 1 && self.state.combat_target.is_none() {
3568 return Ok(());
3569 }
3570 self.seq += 1;
3571 self.session
3572 .submit_intent(Intent::ClearTargetSlot {
3573 entity_id: self.state.entity_id,
3574 slot_index,
3575 seq: self.seq,
3576 })
3577 .await?;
3578 if slot_index == 1 {
3579 self.state.combat_target = None;
3580 self.state.combat_target_label = None;
3581 }
3582 self.state.intents_sent += 1;
3583 self.state
3584 .push_log(format!("Slot {slot_index} target cleared"));
3585 Ok(())
3586 }
3587
3588 pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
3589 self.clear_combat_target_slot(1).await
3590 }
3591
3592 pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
3593 if !self.state.is_alive() {
3594 anyhow::bail!("you are dead");
3595 }
3596 self.seq += 1;
3597 self.session
3598 .submit_intent(Intent::AdvanceRotation {
3599 entity_id: self.state.entity_id,
3600 slot_index,
3601 seq: self.seq,
3602 })
3603 .await?;
3604 self.state.intents_sent += 1;
3605 Ok(())
3606 }
3607
3608 pub async fn assign_slot_preset(&mut self, slot_index: u8, preset_id: &str) -> anyhow::Result<()> {
3609 if !self.state.is_alive() {
3610 anyhow::bail!("you are dead");
3611 }
3612 self.seq += 1;
3613 self.session
3614 .submit_intent(Intent::AssignSlotPreset {
3615 entity_id: self.state.entity_id,
3616 slot_index,
3617 preset_id: preset_id.to_string(),
3618 seq: self.seq,
3619 })
3620 .await?;
3621 self.state.intents_sent += 1;
3622 if let Some(slot) = self
3623 .state
3624 .combat_slots
3625 .iter_mut()
3626 .find(|s| s.slot_index == slot_index)
3627 {
3628 slot.preset_id = Some(preset_id.to_string());
3629 if let Some(preset) = self.state.rotation_presets.iter().find(|p| p.id == preset_id) {
3630 slot.preset_label = Some(preset.label.clone());
3631 slot.rotation = preset.abilities.clone();
3632 slot.rotation_index = 0;
3633 }
3634 }
3635 self.state
3636 .push_log(format!("T{slot_index} loadout → {preset_id}"));
3637 Ok(())
3638 }
3639
3640 pub async fn cast_ability(
3641 &mut self,
3642 ability_id: &str,
3643 target_id: Option<EntityId>,
3644 ) -> anyhow::Result<()> {
3645 if !self.state.is_alive() {
3646 anyhow::bail!("you are dead");
3647 }
3648 let target_id = target_id
3649 .or_else(|| self.state.target_for_slot(2))
3650 .or_else(|| self.state.target_for_slot(1))
3651 .unwrap_or(self.state.entity_id);
3652 self.seq += 1;
3653 self.session
3654 .submit_intent(Intent::Cast {
3655 entity_id: self.state.entity_id,
3656 ability_id: ability_id.to_string(),
3657 target_id,
3658 seq: self.seq,
3659 })
3660 .await?;
3661 self.state.intents_sent += 1;
3662 self.state
3663 .push_log(format!("Cast {ability_id} → {target_id}"));
3664 Ok(())
3665 }
3666
3667 pub async fn upsert_rotation_preset(
3668 &mut self,
3669 preset: RotationPreset,
3670 ) -> anyhow::Result<()> {
3671 self.seq += 1;
3672 self.session
3673 .submit_intent(Intent::UpsertRotationPreset {
3674 entity_id: self.state.entity_id,
3675 preset: preset.clone(),
3676 seq: self.seq,
3677 })
3678 .await?;
3679 self.state.intents_sent += 1;
3680 if let Some(existing) = self
3681 .state
3682 .rotation_presets
3683 .iter_mut()
3684 .find(|p| p.id == preset.id)
3685 {
3686 *existing = preset.clone();
3687 } else {
3688 self.state.rotation_presets.push(preset.clone());
3689 }
3690 for slot in &mut self.state.combat_slots {
3691 if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
3692 slot.preset_label = Some(preset.label.clone());
3693 slot.rotation = preset.abilities.clone();
3694 }
3695 }
3696 self.state
3697 .push_log(format!("Saved rotation: {}", preset.label));
3698 Ok(())
3699 }
3700
3701 pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
3702 self.seq += 1;
3703 self.session
3704 .submit_intent(Intent::DeleteRotationPreset {
3705 entity_id: self.state.entity_id,
3706 preset_id: preset_id.to_string(),
3707 seq: self.seq,
3708 })
3709 .await?;
3710 self.state.intents_sent += 1;
3711 self.state
3712 .rotation_presets
3713 .retain(|p| p.id != preset_id);
3714 for slot in &mut self.state.combat_slots {
3715 if slot.preset_id.as_deref() == Some(preset_id) {
3716 slot.preset_id = None;
3717 slot.preset_label = None;
3718 slot.rotation.clear();
3719 slot.rotation_index = 0;
3720 }
3721 }
3722 self.state
3723 .push_log(format!("Deleted rotation: {preset_id}"));
3724 Ok(())
3725 }
3726
3727 pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
3728 if !self.state.is_alive() {
3729 anyhow::bail!("you are dead");
3730 }
3731 let enabled = !self
3732 .state
3733 .combat_slots
3734 .iter()
3735 .find(|s| s.slot_index == slot_index)
3736 .map(|s| s.auto_enabled)
3737 .unwrap_or(false);
3738 self.seq += 1;
3739 self.session
3740 .submit_intent(Intent::SetAutoAttack {
3741 entity_id: self.state.entity_id,
3742 slot_index,
3743 enabled,
3744 seq: self.seq,
3745 })
3746 .await?;
3747 if slot_index == 1 {
3748 self.state.auto_attack = enabled;
3749 }
3750 self.state.intents_sent += 1;
3751 self.state.push_log(format!(
3752 "T{slot_index} auto {}",
3753 if enabled { "ON" } else { "OFF" }
3754 ));
3755 Ok(())
3756 }
3757
3758 pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
3759 if !self.state.connected {
3760 anyhow::bail!("not connected");
3761 }
3762 if !self.state.is_alive() {
3763 anyhow::bail!("you are dead");
3764 }
3765 let (px, py) = self.state.player_position();
3766 if self
3767 .state
3768 .ground_drops
3769 .iter()
3770 .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
3771 {
3772 anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press p");
3773 }
3774 self.seq += 1;
3775 self.session
3776 .submit_intent(Intent::Pickup {
3777 entity_id: self.state.entity_id,
3778 drop_id: None,
3779 seq: self.seq,
3780 })
3781 .await?;
3782 self.state.intents_sent += 1;
3783 Ok(())
3784 }
3785
3786 pub async fn toggle_auto_attack(&mut self) -> anyhow::Result<()> {
3787 self.toggle_auto_attack_slot(1).await
3788 }
3789
3790 pub async fn dodge(&mut self) -> anyhow::Result<()> {
3791 if !self.state.is_alive() {
3792 anyhow::bail!("you are dead");
3793 }
3794 self.seq += 1;
3795 self.session
3796 .submit_intent(Intent::Dodge {
3797 entity_id: self.state.entity_id,
3798 seq: self.seq,
3799 })
3800 .await?;
3801 self.state.intents_sent += 1;
3802 self.state.push_log("Dodge!");
3803 Ok(())
3804 }
3805
3806 pub async fn lunge(&mut self) -> anyhow::Result<()> {
3807 if !self.state.is_alive() {
3808 anyhow::bail!("you are dead");
3809 }
3810 let (forward, strafe) = self.last_move_axes();
3811 self.seq += 1;
3812 self.session
3813 .submit_intent(Intent::Lunge {
3814 entity_id: self.state.entity_id,
3815 forward,
3816 strafe,
3817 seq: self.seq,
3818 })
3819 .await?;
3820 self.state.intents_sent += 1;
3821 self.state.push_log("Lunge!");
3822 Ok(())
3823 }
3824
3825 pub fn last_move_axes(&self) -> (f32, f32) {
3827 (self.last_move_forward, self.last_move_strafe)
3828 }
3829
3830 pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
3831 if !self.state.is_alive() {
3832 anyhow::bail!("you are dead");
3833 }
3834 self.seq += 1;
3835 self.session
3836 .submit_intent(Intent::Block {
3837 entity_id: self.state.entity_id,
3838 enabled,
3839 seq: self.seq,
3840 })
3841 .await?;
3842 self.state.intents_sent += 1;
3843 if enabled {
3844 self.state.push_log("Blocking");
3845 }
3846 Ok(())
3847 }
3848
3849 pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
3850 if !self.state.is_alive() {
3851 anyhow::bail!("you are dead");
3852 }
3853 self.seq += 1;
3854 self.session
3855 .submit_intent(Intent::EquipMainhand {
3856 entity_id: self.state.entity_id,
3857 template_id,
3858 seq: self.seq,
3859 })
3860 .await?;
3861 self.state.intents_sent += 1;
3862 Ok(())
3863 }
3864
3865 pub async fn say(&mut self, channel: flatland_protocol::ChatChannel, text: &str) -> anyhow::Result<()> {
3866 self.seq += 1;
3867 self.session
3868 .submit_intent(Intent::Say {
3869 entity_id: self.state.entity_id,
3870 channel,
3871 text: text.to_string(),
3872 seq: self.seq,
3873 })
3874 .await?;
3875 self.state.intents_sent += 1;
3876 Ok(())
3877 }
3878
3879 pub async fn stop(&mut self) -> anyhow::Result<()> {
3880 self.seq += 1;
3881 self.session
3882 .submit_intent(Intent::Stop {
3883 entity_id: self.state.entity_id,
3884 seq: self.seq,
3885 })
3886 .await?;
3887 self.state.intents_sent += 1;
3888 Ok(())
3889 }
3890
3891 pub fn disconnect(&self) {
3892 self.session.disconnect();
3893 }
3894}
3895
3896fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
3897 let dx = ax - bx;
3898 let dy = ay - by;
3899 (dx * dx + dy * dy).sqrt()
3900}
3901
3902#[cfg(test)]
3903mod tests {
3904 use std::collections::BTreeMap;
3905
3906 use super::*;
3907 use flatland_protocol::{
3908 BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
3909 };
3910
3911 fn sample_state() -> GameState {
3912 let mut state = GameState {
3913 session_id: 1,
3914 entity_id: 1,
3915 character_id: None,
3916 tick: 0,
3917 chunk_rev: 0,
3918 content_rev: 0,
3919 entities: vec![EntityState {
3920 id: 1,
3921 label: "You".into(),
3922 transform: Transform {
3923 position: WorldCoord::surface(128.0, 128.0),
3924 yaw: 0.0,
3925 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
3926 },
3927 vitals: None,
3928 attributes: None,
3929 skills: None,
3930 inside_building: None,
3931 }],
3932 player: None,
3933 resource_nodes: vec![ResourceNodeView {
3934 id: "oak-1".into(),
3935 label: "Oak".into(),
3936 x: 130.0,
3937 y: 128.0,
3938 z: 0.0,
3939 item_template: "oak_log".into(),
3940 state: ResourceNodeState::Available,
3941 blocking: true,
3942 blocking_radius_m: 0.8,
3943 }],
3944 ground_drops: vec![],
3945 placed_containers: vec![],
3946 buildings: vec![BuildingView {
3947 id: "broker-hut".into(),
3948 label: "Broker".into(),
3949 x: 148.0,
3950 y: 118.0,
3951 width_m: 8.0,
3952 depth_m: 6.0,
3953 interior_origin_x: 404.0,
3954 interior_origin_y: 403.0,
3955 tags: vec![],
3956 }],
3957 doors: vec![flatland_protocol::DoorView {
3958 id: "door-1".into(),
3959 building_id: "broker-hut".into(),
3960 x: 148.0,
3961 y: 118.0,
3962 open: false,
3963 is_exit: false,
3964 }],
3965 npcs: vec![],
3966 blueprints: vec![],
3967 world_width_m: 256.0,
3968 world_height_m: 256.0,
3969 terrain_zones: Vec::new(),
3970 world_clock: flatland_protocol::WorldClock::default(),
3971 inventory: std::collections::HashMap::new(),
3972 inventory_hints: std::collections::HashMap::new(),
3973 logs: VecDeque::new(),
3974 intents_sent: 0,
3975 ticks_received: 0,
3976 connected: true,
3977 disconnect_reason: None,
3978 show_stats: false,
3979 show_craft_menu: false,
3980 craft_menu_index: 0,
3981 craft_batch_quantity: 1,
3982 show_shop_menu: false,
3983 shop_catalog: None,
3984 shop_tab: ShopTab::default(),
3985 shop_menu_index: 0,
3986 shop_quantity: 1,
3987 show_inventory_menu: false,
3988 inventory_menu_index: 0,
3989 show_move_picker: false,
3990 show_rename_prompt: false,
3991 rename_buffer: String::new(),
3992 move_picker_index: 0,
3993 move_picker: None,
3994 show_destroy_picker: false,
3995 destroy_confirm_pending: false,
3996 destroy_picker: None,
3997 combat_target: None,
3998 combat_target_label: None,
3999 in_combat: false,
4000 auto_attack: true,
4001 combat_has_los: false,
4002 attack_cd_ticks: 0,
4003 gcd_ticks: 0,
4004 weapon_ability_id: "unarmed".into(),
4005 mainhand_template_id: None,
4006 mainhand_label: None,
4007 worn: BTreeMap::new(),
4008 carry_mass: 0.0,
4009 carry_mass_max: 0.0,
4010 encumbrance: flatland_protocol::EncumbranceState::Light,
4011 inventory_stacks: Vec::new(),
4012 combat_target_detail: None,
4013 cast_progress: None,
4014 ability_cooldowns: Vec::new(),
4015 blocking_active: false,
4016 max_target_slots: 1,
4017 combat_slots: Vec::new(),
4018 rotation_presets: Vec::new(),
4019 show_loadout_menu: false,
4020 show_rotation_editor: false,
4021 loadout_menu_index: 0,
4022 rotation_editor: RotationEditorState::default(),
4023 harvest_in_progress: false,
4024 harvest_started_at: None,
4025 pending_craft_ack: None,
4026 };
4027 state.player = state.entities.first().cloned();
4028 state
4029 }
4030
4031 #[test]
4032 fn empty_entity_tick_preserves_welcome_snapshot() {
4033 let mut state = sample_state();
4034 state.inventory.insert("carrot".into(), 3);
4035 let delta = TickDelta {
4036 tick: 1,
4037 entities: vec![],
4038 resource_nodes: vec![],
4039 ground_drops: vec![],
4040 placed_containers: vec![],
4041 buildings: vec![],
4042 doors: vec![],
4043 npcs: vec![],
4044 inventory: vec![],
4045 blueprints: vec![],
4046 world_clock: flatland_protocol::WorldClock::default(),
4047 combat: None,
4048 };
4049
4050 state.apply_tick_fields(&delta, 1);
4051
4052 assert_eq!(state.entities.len(), 1);
4053 assert!(state.player.is_some());
4054 assert_eq!(state.inventory.get("carrot"), Some(&3));
4055 assert_eq!(state.resource_nodes.len(), 1);
4056 }
4057
4058 #[test]
4059 fn tick_preserves_world_layers_when_delta_omits_them() {
4060 let mut state = sample_state();
4061 let delta = TickDelta {
4062 tick: 1,
4063 entities: state.entities.clone(),
4064 resource_nodes: vec![],
4065 ground_drops: vec![],
4066 placed_containers: vec![],
4067 buildings: vec![],
4068 doors: vec![],
4069 npcs: vec![],
4070 inventory: vec![],
4071 blueprints: vec![],
4072 world_clock: flatland_protocol::WorldClock::default(),
4073 combat: None,
4074 };
4075
4076 state.apply_tick_fields(&delta, 1);
4077
4078 assert_eq!(state.resource_nodes.len(), 1);
4079 assert_eq!(state.buildings.len(), 1);
4080 assert_eq!(state.doors.len(), 1);
4081 }
4082
4083 #[test]
4084 fn tick_updates_resource_nodes_when_server_sends_them() {
4085 let mut state = sample_state();
4086 let delta = TickDelta {
4087 tick: 1,
4088 entities: state.entities.clone(),
4089 resource_nodes: vec![ResourceNodeView {
4090 id: "oak-1".into(),
4091 label: "Oak".into(),
4092 x: 130.0,
4093 y: 128.0,
4094 z: 0.0,
4095 item_template: "oak_log".into(),
4096 state: ResourceNodeState::Cooldown,
4097 blocking: true,
4098 blocking_radius_m: 0.8,
4099 }],
4100 buildings: vec![],
4101 doors: vec![],
4102 npcs: vec![],
4103 inventory: vec![],
4104 blueprints: vec![],
4105 world_clock: flatland_protocol::WorldClock::default(),
4106 ground_drops: vec![],
4107 placed_containers: vec![],
4108 combat: None,
4109 };
4110
4111 state.apply_tick_fields(&delta, 1);
4112
4113 assert!(matches!(
4114 state.resource_nodes[0].state,
4115 ResourceNodeState::Cooldown
4116 ));
4117 }
4118
4119 #[test]
4120 fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
4121 let mut state = GameState {
4122 session_id: 1,
4123 entity_id: 1,
4124 character_id: None,
4125 tick: 0,
4126 chunk_rev: 0,
4127 content_rev: 0,
4128 entities: vec![EntityState {
4129 id: 1,
4130 label: "You".into(),
4131 transform: Transform {
4132 position: WorldCoord::surface(406.0, 403.0),
4133 yaw: 0.0,
4134 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
4135 },
4136 vitals: None,
4137 attributes: None,
4138 skills: None,
4139 inside_building: None,
4140 }],
4141 player: None,
4142 resource_nodes: vec![],
4143 ground_drops: vec![],
4144 placed_containers: vec![],
4145 buildings: vec![BuildingView {
4146 id: "broker_hut".into(),
4147 label: "Broker".into(),
4148 x: 158.0,
4149 y: 124.0,
4150 width_m: 8.0,
4151 depth_m: 6.0,
4152 interior_origin_x: 400.0,
4153 interior_origin_y: 400.0,
4154 tags: vec![],
4155 }],
4156 doors: vec![flatland_protocol::DoorView {
4157 id: "broker_hut_exit".into(),
4158 building_id: "broker_hut".into(),
4159 x: 404.0,
4160 y: 400.0,
4161 open: true,
4162 is_exit: true,
4163 }],
4164 npcs: vec![flatland_protocol::NpcView {
4165 id: "ada_broker".into(),
4166 label: "Ada".into(),
4167 x: 406.0,
4168 y: 403.0,
4169 building_id: Some("broker_hut".into()),
4170 role: "broker".into(),
4171 entity_id: None,
4172 life_state: None,
4173 hp_pct: None,
4174 }],
4175 blueprints: vec![],
4176 world_width_m: 256.0,
4177 world_height_m: 256.0,
4178 terrain_zones: Vec::new(),
4179 world_clock: flatland_protocol::WorldClock::default(),
4180 inventory: std::collections::HashMap::new(),
4181 inventory_hints: std::collections::HashMap::new(),
4182 logs: VecDeque::new(),
4183 intents_sent: 0,
4184 ticks_received: 0,
4185 connected: true,
4186 disconnect_reason: None,
4187 show_stats: false,
4188 show_craft_menu: false,
4189 craft_menu_index: 0,
4190 craft_batch_quantity: 1,
4191 show_shop_menu: false,
4192 shop_catalog: None,
4193 shop_tab: ShopTab::default(),
4194 shop_menu_index: 0,
4195 shop_quantity: 1,
4196 show_inventory_menu: false,
4197 inventory_menu_index: 0,
4198 show_move_picker: false,
4199 show_rename_prompt: false,
4200 rename_buffer: String::new(),
4201 move_picker_index: 0,
4202 move_picker: None,
4203 show_destroy_picker: false,
4204 destroy_confirm_pending: false,
4205 destroy_picker: None,
4206 combat_target: None,
4207 combat_target_label: None,
4208 in_combat: false,
4209 auto_attack: true,
4210 combat_has_los: false,
4211 attack_cd_ticks: 0,
4212 gcd_ticks: 0,
4213 weapon_ability_id: "unarmed".into(),
4214 mainhand_template_id: None,
4215 mainhand_label: None,
4216 worn: BTreeMap::new(),
4217 carry_mass: 0.0,
4218 carry_mass_max: 0.0,
4219 encumbrance: flatland_protocol::EncumbranceState::Light,
4220 inventory_stacks: Vec::new(),
4221 combat_target_detail: None,
4222 cast_progress: None,
4223 ability_cooldowns: Vec::new(),
4224 blocking_active: false,
4225 max_target_slots: 1,
4226 combat_slots: Vec::new(),
4227 rotation_presets: Vec::new(),
4228 show_loadout_menu: false,
4229 show_rotation_editor: false,
4230 loadout_menu_index: 0,
4231 rotation_editor: RotationEditorState::default(),
4232 harvest_in_progress: false,
4233 harvest_started_at: None,
4234 pending_craft_ack: None,
4235 };
4236 state.player = state.entities.first().cloned();
4237 assert_eq!(
4238 state.nearest_interact_target().as_deref(),
4239 Some("ada_broker")
4240 );
4241 }
4242
4243 #[test]
4244 fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
4245 let mut state = sample_state();
4246 state.placed_containers = vec![
4249 flatland_protocol::PlacedContainerView {
4250 id: "near".into(),
4251 template_id: "wooden_chest_small".into(),
4252 display_name: "Wooden Chest".into(),
4253 x: 130.0,
4254 y: 128.0,
4255 z: 0.0,
4256 locked: true,
4257 accessible: true,
4258 owner_character_id: None,
4259 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
4260 lock_id: None,
4261 capacity_volume: None,
4262 item_instance_id: Some(uuid::Uuid::from_u128(1)),
4263 },
4264 flatland_protocol::PlacedContainerView {
4265 id: "far".into(),
4266 template_id: "wooden_chest_small".into(),
4267 display_name: "Distant Chest".into(),
4268 x: 128.0 + CONTAINER_RANGE_M + 5.0,
4269 y: 128.0,
4270 z: 0.0,
4271 locked: false,
4272 accessible: true,
4273 owner_character_id: None,
4274 contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
4275 lock_id: None,
4276 capacity_volume: None,
4277 item_instance_id: Some(uuid::Uuid::from_u128(2)),
4278 },
4279 ];
4280
4281 let nearby = state.nearby_containers();
4282 assert_eq!(nearby.len(), 1, "far chest must not appear once out of range");
4283 assert_eq!(nearby[0].view.id, "near");
4284 assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
4285 assert!(nearby[0].rows[0].is_chest_shell);
4286
4287 state.placed_containers[0].accessible = false;
4290 let nearby = state.nearby_containers();
4291 assert_eq!(nearby.len(), 1);
4292 assert_eq!(nearby[0].rows.len(), 1);
4293 assert!(nearby[0].rows[0].is_chest_shell);
4294 }
4295
4296 #[test]
4297 fn placed_container_public_label_hides_owner_custom_name() {
4298 let owner = uuid::Uuid::from_u128(99);
4299 let mut state = sample_state();
4300 state.character_id = Some(uuid::Uuid::from_u128(1));
4301 state.inventory_hints.insert(
4302 "wooden_chest_medium".into(),
4303 InventoryHint {
4304 display_name: "Medium Wooden Chest".into(),
4305 category: "container".into(),
4306 base_mass: None,
4307 base_volume: None,
4308 capacity_volume: None,
4309 stackable: false,
4310 },
4311 );
4312 let chest = flatland_protocol::PlacedContainerView {
4313 id: "c1".into(),
4314 template_id: "wooden_chest_medium".into(),
4315 display_name: "Barry's Loot #a3f2".into(),
4316 x: 128.0,
4317 y: 128.0,
4318 z: 0.0,
4319 locked: false,
4320 accessible: true,
4321 owner_character_id: Some(owner),
4322 contents: vec![],
4323 lock_id: None,
4324 capacity_volume: None,
4325 item_instance_id: None,
4326 };
4327 assert_eq!(
4328 state.placed_container_public_label(&chest),
4329 "Medium Wooden Chest"
4330 );
4331 state.character_id = Some(owner);
4332 assert_eq!(
4333 state.placed_container_public_label(&chest),
4334 "Barry's Loot #a3f2"
4335 );
4336 }
4337
4338 #[test]
4339 fn location_context_lists_nearby_resource_node() {
4340 let mut state = sample_state();
4341 state.player = state.entities.first().cloned();
4342 state.resource_nodes[0].x = 128.2;
4343 state.resource_nodes[0].y = 128.0;
4344 let lines = state.location_context_lines();
4345 assert!(
4346 lines.iter().any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
4347 "expected resource node in context: {:?}",
4348 lines
4349 );
4350 }
4351
4352 #[test]
4353 fn inventory_selectable_rows_orders_worn_before_person_before_nearby() {
4354 let mut state = sample_state();
4355 state.worn.insert(
4356 BodySlot::Back,
4357 flatland_protocol::ItemStack {
4358 template_id: "travel_backpack".into(),
4359 quantity: 1,
4360 item_instance_id: Some(uuid::Uuid::from_u128(3)),
4361 props: Default::default(),
4362 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
4363 display_name: None,
4364 category: None,
4365 base_mass: None,
4366 base_volume: None,
4367 capacity_volume: None,
4368 stackable: None,
4369 },
4370 );
4371 state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
4372 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
4373 id: "chest-1".into(),
4374 template_id: "wooden_chest_small".into(),
4375 display_name: "Wooden Chest".into(),
4376 x: 129.0,
4377 y: 128.0,
4378 z: 0.0,
4379 locked: false,
4380 accessible: true,
4381 owner_character_id: None,
4382 contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
4383 lock_id: None,
4384 capacity_volume: None,
4385 item_instance_id: Some(uuid::Uuid::from_u128(4)),
4386 }];
4387
4388 let rows = state.inventory_selectable_rows();
4389 let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
4390 assert_eq!(
4391 sections,
4392 vec![
4393 InventorySection::Worn, InventorySection::Worn, InventorySection::Person, InventorySection::Nearby, InventorySection::Nearby, ]
4399 );
4400 assert_eq!(rows[0].stack.template_id, "travel_backpack");
4401 assert!(rows[0].is_equip_shell);
4402 assert_eq!(rows[1].stack.template_id, "iron_ore");
4403 assert_eq!(rows[1].depth, 1);
4404 assert_eq!(rows[2].stack.template_id, "lumber");
4405 assert!(rows[3].is_chest_shell);
4406 assert_eq!(rows[4].stack.template_id, "wood_axe");
4407 assert_eq!(rows[4].depth, 1);
4408 }
4409
4410 #[test]
4411 fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
4412 let mut state = sample_state();
4413 let back_id = uuid::Uuid::from_u128(5);
4414 state.worn.insert(
4415 BodySlot::Back,
4416 flatland_protocol::ItemStack {
4417 template_id: "travel_backpack".into(),
4418 quantity: 1,
4419 item_instance_id: Some(back_id),
4420 props: Default::default(),
4421 contents: Vec::new(),
4422 display_name: None,
4423 category: Some("container".into()),
4424 base_mass: None,
4425 base_volume: None,
4426 capacity_volume: Some(80.0),
4427 stackable: None,
4428 },
4429 );
4430 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
4431 id: "chest-1".into(),
4432 template_id: "wooden_chest_small".into(),
4433 display_name: "Wooden Chest".into(),
4434 x: 129.0,
4435 y: 128.0,
4436 z: 0.0,
4437 locked: false,
4438 accessible: true,
4439 owner_character_id: None,
4440 contents: Vec::new(),
4441 lock_id: None,
4442 capacity_volume: None,
4443 item_instance_id: Some(uuid::Uuid::from_u128(6)),
4444 }];
4445
4446 let opts = state.move_destinations_for(
4449 &flatland_protocol::InventoryLocation::Root,
4450 None,
4451 None,
4452 "lumber",
4453 );
4454 assert!(!opts.iter().any(|o| matches!(
4455 &o.kind,
4456 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
4457 )));
4458 assert!(opts.iter().any(|o| matches!(
4459 &o.kind,
4460 MoveOptionKind::Move { location, parent_instance_id, .. }
4461 if *location == flatland_protocol::InventoryLocation::Worn {
4462 slot: BodySlot::Back,
4463 } && *parent_instance_id == Some(back_id)
4464 )));
4465 assert!(opts.iter().any(|o| matches!(
4466 &o.kind,
4467 MoveOptionKind::Move { location, .. }
4468 if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
4469 )));
4470 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
4471 assert!(matches!(
4472 opts[opts.len() - 2].kind,
4473 MoveOptionKind::Drop
4474 ));
4475
4476 let from_backpack = flatland_protocol::InventoryLocation::Worn {
4480 slot: BodySlot::Back,
4481 };
4482 let opts = state.move_destinations_for(
4483 &from_backpack,
4484 Some(back_id),
4485 None,
4486 "iron_ore",
4487 );
4488 assert!(!opts.iter().any(|o| matches!(
4489 &o.kind,
4490 MoveOptionKind::Move { location, parent_instance_id, .. }
4491 if *location == from_backpack && *parent_instance_id == Some(back_id)
4492 )));
4493 assert!(opts.iter().any(|o| matches!(
4494 &o.kind,
4495 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
4496 )));
4497 }
4498
4499 #[test]
4500 fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
4501 let mut state = sample_state();
4502 state.worn.insert(
4505 BodySlot::Waist,
4506 flatland_protocol::ItemStack {
4507 template_id: "simple_belt".into(),
4508 quantity: 1,
4509 item_instance_id: Some(uuid::Uuid::from_u128(10)),
4510 props: Default::default(),
4511 contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
4512 display_name: None,
4513 category: Some("container".into()),
4514 base_mass: None,
4515 base_volume: None,
4516 capacity_volume: None,
4517 stackable: None,
4518 },
4519 );
4520 state.worn.insert(
4521 BodySlot::Head,
4522 flatland_protocol::ItemStack {
4523 template_id: "cloth_cap".into(),
4524 quantity: 1,
4525 item_instance_id: Some(uuid::Uuid::from_u128(11)),
4526 props: Default::default(),
4527 contents: Vec::new(),
4528 display_name: None,
4529 category: Some("armor".into()),
4530 base_mass: None,
4531 base_volume: None,
4532 capacity_volume: None,
4533 stackable: None,
4534 },
4535 );
4536 state.worn.insert(
4537 BodySlot::Back,
4538 flatland_protocol::ItemStack {
4539 template_id: "travel_backpack".into(),
4540 quantity: 1,
4541 item_instance_id: Some(uuid::Uuid::from_u128(12)),
4542 props: Default::default(),
4543 contents: Vec::new(),
4544 display_name: None,
4545 category: Some("container".into()),
4546 base_mass: None,
4547 base_volume: None,
4548 capacity_volume: None,
4549 stackable: None,
4550 },
4551 );
4552
4553 let rows = state.worn_rows();
4554 assert_eq!(rows.len(), 4);
4556 assert_eq!(rows[0].stack.template_id, "cloth_cap");
4557 assert!(rows[0].is_equip_shell);
4558 assert_eq!(rows[1].stack.template_id, "travel_backpack");
4559 assert!(rows[1].is_equip_shell);
4560 assert_eq!(rows[2].stack.template_id, "simple_belt");
4561 assert!(rows[2].is_equip_shell);
4562 assert_eq!(rows[3].stack.template_id, "leather_pouch");
4563 assert_eq!(rows[3].depth, 1);
4564 assert!(!rows[3].is_equip_shell);
4565 }
4566
4567 #[test]
4568 fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
4569 let mut state = sample_state();
4570 state.worn.insert(
4571 BodySlot::Waist,
4572 flatland_protocol::ItemStack {
4573 template_id: "simple_belt".into(),
4574 quantity: 1,
4575 item_instance_id: Some(uuid::Uuid::from_u128(20)),
4576 props: Default::default(),
4577 contents: Vec::new(),
4578 display_name: Some("Simple Belt".into()),
4579 category: Some("container".into()),
4580 base_mass: None,
4581 base_volume: None,
4582 capacity_volume: None,
4583 stackable: None,
4584 },
4585 );
4586 state.worn.insert(
4587 BodySlot::Head,
4588 flatland_protocol::ItemStack {
4589 template_id: "cloth_cap".into(),
4590 quantity: 1,
4591 item_instance_id: Some(uuid::Uuid::from_u128(21)),
4592 props: Default::default(),
4593 contents: Vec::new(),
4594 display_name: Some("Cloth Cap".into()),
4595 category: Some("armor".into()),
4596 base_mass: None,
4597 base_volume: None,
4598 capacity_volume: None,
4599 stackable: None,
4600 },
4601 );
4602
4603 let opts = state.move_destinations_for(
4604 &flatland_protocol::InventoryLocation::Root,
4605 None,
4606 None,
4607 "leather_pouch",
4608 );
4609 assert!(
4610 opts.iter().any(|o| matches!(
4611 &o.kind,
4612 MoveOptionKind::Move { location, .. }
4613 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
4614 )),
4615 "belt loop must be offered when moving a pouch"
4616 );
4617 assert!(
4618 !opts.iter().any(|o| matches!(
4619 &o.kind,
4620 MoveOptionKind::Move { location, .. }
4621 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
4622 )),
4623 "armor slots can't hold other items and must not appear as move destinations"
4624 );
4625 let belt_opt = opts
4626 .iter()
4627 .find(|o| matches!(
4628 &o.kind,
4629 MoveOptionKind::Move { location, .. }
4630 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
4631 ))
4632 .unwrap();
4633 assert!(belt_opt.label.contains("belt loop"));
4634
4635 let opts = state.move_destinations_for(
4636 &flatland_protocol::InventoryLocation::Root,
4637 None,
4638 None,
4639 "lumber",
4640 );
4641 assert!(
4642 !opts.iter().any(|o| o.label.contains("belt loop")),
4643 "loose materials must not target the belt shell — only nested pouches"
4644 );
4645 }
4646
4647 #[test]
4648 fn move_destinations_for_offers_dimensional_pouch_on_belt() {
4649 let mut state = sample_state();
4650 let belt_id = uuid::Uuid::from_u128(30);
4651 let pouch_id = uuid::Uuid::from_u128(31);
4652 state.worn.insert(
4653 BodySlot::Waist,
4654 flatland_protocol::ItemStack {
4655 template_id: "simple_belt".into(),
4656 quantity: 1,
4657 item_instance_id: Some(belt_id),
4658 props: Default::default(),
4659 contents: vec![flatland_protocol::ItemStack {
4660 template_id: "dimensional_pouch".into(),
4661 quantity: 1,
4662 item_instance_id: Some(pouch_id),
4663 props: Default::default(),
4664 contents: Vec::new(),
4665 display_name: Some("Dimensional Pouch".into()),
4666 category: Some("container".into()),
4667 base_mass: None,
4668 base_volume: None,
4669 capacity_volume: Some(200.0),
4670 stackable: None,
4671 }],
4672 display_name: Some("Simple Belt".into()),
4673 category: Some("container".into()),
4674 base_mass: None,
4675 base_volume: None,
4676 capacity_volume: None,
4677 stackable: None,
4678 },
4679 );
4680
4681 let opts = state.move_destinations_for(
4682 &flatland_protocol::InventoryLocation::Root,
4683 None,
4684 None,
4685 "iron_ore",
4686 );
4687 assert!(
4688 opts.iter().any(|o| matches!(
4689 &o.kind,
4690 MoveOptionKind::Move {
4691 location,
4692 parent_instance_id,
4693 ..
4694 } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
4695 && *parent_instance_id == Some(pouch_id)
4696 )),
4697 "dimensional pouch clipped on belt must accept loose items"
4698 );
4699 assert!(
4700 opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
4701 "destination label should name the pouch"
4702 );
4703 }
4704
4705 #[test]
4706 fn container_volume_label_on_placed_chest_shell() {
4707 let mut state = sample_state();
4708 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
4709 id: "chest-1".into(),
4710 template_id: "wooden_chest_small".into(),
4711 display_name: "Camp Chest".into(),
4712 x: 129.0,
4713 y: 128.0,
4714 z: 0.0,
4715 locked: false,
4716 accessible: true,
4717 owner_character_id: None,
4718 contents: vec![flatland_protocol::ItemStack {
4719 template_id: "iron_ore".into(),
4720 quantity: 2,
4721 item_instance_id: None,
4722 props: Default::default(),
4723 contents: Vec::new(),
4724 display_name: None,
4725 category: None,
4726 base_mass: None,
4727 base_volume: Some(2.0),
4728 capacity_volume: None,
4729 stackable: None,
4730 }],
4731 lock_id: None,
4732 capacity_volume: Some(60.0),
4733 item_instance_id: Some(uuid::Uuid::from_u128(4)),
4734 }];
4735 let nearby = state.nearby_containers();
4736 let label = state.container_volume_label(&nearby[0].rows[0]);
4737 assert!(
4738 label.contains("vol 4/60"),
4739 "expected used/cap in label, got {label}"
4740 );
4741 assert!(label.contains("56 free"), "expected free space, got {label}");
4742 }
4743
4744 #[test]
4745 fn key_pair_chest_label_from_placed_lock_id() {
4746 let mut state = sample_state();
4747 let owner = uuid::Uuid::from_u128(77);
4748 state.character_id = Some(owner);
4749 let lock = uuid::Uuid::from_u128(99).to_string();
4750 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
4751 id: "chest-1".into(),
4752 template_id: "wooden_chest_small".into(),
4753 display_name: "Barry's Loot #a3f2".into(),
4754 x: 129.0,
4755 y: 128.0,
4756 z: 0.0,
4757 locked: true,
4758 accessible: true,
4759 owner_character_id: Some(owner),
4760 contents: Vec::new(),
4761 lock_id: Some(lock.clone()),
4762 capacity_volume: None,
4763 item_instance_id: Some(uuid::Uuid::from_u128(4)),
4764 }];
4765 let key_id = uuid::Uuid::from_u128(5);
4766 let key = flatland_protocol::ItemStack {
4767 template_id: KEY_TEMPLATE.into(),
4768 quantity: 1,
4769 item_instance_id: Some(key_id),
4770 props: BTreeMap::from([
4771 (PROP_OPENS_LOCK_ID.into(), lock),
4772 (PROP_OPENS_CONTAINER_NAME.into(), "Barry's Loot #a3f2".into()),
4773 ]),
4774 contents: Vec::new(),
4775 display_name: Some("Container Key".into()),
4776 category: Some("key".into()),
4777 base_mass: None,
4778 base_volume: None,
4779 capacity_volume: None,
4780 stackable: None,
4781 };
4782 state.inventory_stacks = vec![key.clone()];
4783 assert_eq!(
4784 state.key_pair_chest_label(&key).as_deref(),
4785 Some("Barry's Loot #a3f2")
4786 );
4787 assert!(state.key_drop_blocked(&key));
4788 }
4789
4790 #[test]
4791 fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
4792 let mut state = sample_state();
4793 let lock = uuid::Uuid::from_u128(101).to_string();
4794 let key = flatland_protocol::ItemStack {
4795 template_id: KEY_TEMPLATE.into(),
4796 quantity: 1,
4797 item_instance_id: Some(uuid::Uuid::from_u128(7)),
4798 props: BTreeMap::from([
4799 (PROP_OPENS_LOCK_ID.into(), lock),
4800 (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
4801 ]),
4802 contents: Vec::new(),
4803 display_name: None,
4804 category: Some("key".into()),
4805 base_mass: None,
4806 base_volume: None,
4807 capacity_volume: None,
4808 stackable: None,
4809 };
4810 state.placed_containers.clear();
4811 assert_eq!(
4812 state.key_pair_chest_label(&key).as_deref(),
4813 Some("Camp Stash")
4814 );
4815 }
4816
4817 #[test]
4818 fn key_drop_allowed_when_paired_chest_unlocked() {
4819 let mut state = sample_state();
4820 let lock = uuid::Uuid::from_u128(100).to_string();
4821 let key_id = uuid::Uuid::from_u128(6);
4822 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
4823 id: "chest-1".into(),
4824 template_id: "wooden_chest_small".into(),
4825 display_name: "Camp Chest".into(),
4826 x: 129.0,
4827 y: 128.0,
4828 z: 0.0,
4829 locked: false,
4830 accessible: true,
4831 owner_character_id: None,
4832 contents: Vec::new(),
4833 lock_id: Some(lock.clone()),
4834 capacity_volume: None,
4835 item_instance_id: None,
4836 }];
4837 let key = flatland_protocol::ItemStack {
4838 template_id: KEY_TEMPLATE.into(),
4839 quantity: 1,
4840 item_instance_id: Some(key_id),
4841 props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
4842 contents: Vec::new(),
4843 display_name: None,
4844 category: Some("key".into()),
4845 base_mass: None,
4846 base_volume: None,
4847 capacity_volume: None,
4848 stackable: None,
4849 };
4850 state.inventory_stacks = vec![key.clone()];
4851 assert!(!state.key_drop_blocked(&key));
4852 let opts = state.move_destinations_for(
4853 &flatland_protocol::InventoryLocation::Root,
4854 None,
4855 Some(key_id),
4856 KEY_TEMPLATE,
4857 );
4858 assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
4859 }
4860}