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