1use std::collections::BTreeSet;
16
17use flatland_protocol::{
18 ItemCatalogEntryView, ItemStack, NpcView, PlacedContainerView, ResourceNodeView,
19 WorkerRouteKindView, WorkerRouteStopView, WorkerRouteView,
20};
21
22#[derive(Debug, Clone, PartialEq)]
24pub struct WorkerRouteWaypoint {
25 pub x: f32,
26 pub y: f32,
27 pub z: f32,
28}
29
30#[derive(Debug, Clone, PartialEq)]
32pub enum WorkerRouteStop {
33 Waypoint {
34 x: f32,
35 y: f32,
36 z: f32,
37 },
38 HarvestNode {
39 node_id: String,
40 },
41 DepositAt {
42 container_id: String,
43 filter: Option<Vec<String>>,
45 },
46 TradeWith {
49 npc_id: Option<String>,
50 template: String,
51 sell_all: bool,
52 },
53 WithdrawFrom {
55 container_id: String,
56 items: Vec<WorkerRouteWithdrawItem>,
57 },
58 CraftAt {
60 device: String,
61 blueprint: String,
62 qty: Option<u32>,
63 },
64 CultivatePlot {
65 plot_id: uuid::Uuid,
66 },
67 PlantPlot {
68 plot_id: uuid::Uuid,
69 seed_template: String,
70 },
71 HarvestPlot {
72 plot_id: uuid::Uuid,
73 },
74 RestIfNeeded,
75 Wait {
76 wait_ticks: u64,
77 },
78}
79
80#[derive(Debug, Clone, PartialEq)]
82pub struct WorkerRouteWithdrawItem {
83 pub template: String,
84 pub qty: Option<u32>,
87}
88
89impl WorkerRouteStop {
90 pub fn summary(&self) -> String {
91 self.summary_resolved(
92 |id| short_id(id),
93 |id| id.to_string(),
94 |id| id.to_string(),
95 |id| format!("plot {}", short_plot_id(id)),
96 )
97 }
98
99 pub fn summary_resolved(
101 &self,
102 container_label: impl Fn(&str) -> String,
103 npc_label: impl Fn(&str) -> String,
104 node_label: impl Fn(&str) -> String,
105 plot_label: impl Fn(&uuid::Uuid) -> String,
106 ) -> String {
107 match self {
108 Self::Waypoint { x, y, .. } => format!("waypoint ({x:.0}, {y:.0})"),
109 Self::HarvestNode { node_id } => format!("harvest {}", node_label(node_id)),
110 Self::DepositAt {
111 container_id,
112 filter,
113 } => {
114 let f = filter
115 .as_ref()
116 .map(|f| format!(" only {}", f.join(",")))
117 .unwrap_or_default();
118 format!("deposit at {}{f}", container_label(container_id))
119 }
120 Self::TradeWith {
121 npc_id, template, ..
122 } => {
123 let who = npc_id
124 .as_deref()
125 .map(|id| npc_label(id))
126 .unwrap_or_else(|| "nearest buyer".into());
127 format!("sell {template} to {who}")
128 }
129 Self::WithdrawFrom {
130 container_id,
131 items,
132 } => {
133 let what = items
134 .iter()
135 .map(|i| match i.qty {
136 None => format!("all {}", i.template),
137 Some(q) => format!("up to {q} {}", i.template),
138 })
139 .collect::<Vec<_>>()
140 .join(" + ");
141 format!("withdraw {what} from {}", container_label(container_id))
142 }
143 Self::CraftAt { blueprint, .. } => format!("craft {blueprint}"),
144 Self::CultivatePlot { plot_id } => {
145 format!("cultivate {}", plot_label(plot_id))
146 }
147 Self::PlantPlot {
148 plot_id,
149 seed_template,
150 } => format!("plant {seed_template} on {}", plot_label(plot_id)),
151 Self::HarvestPlot { plot_id } => {
152 format!("harvest {}", plot_label(plot_id))
153 }
154 Self::RestIfNeeded => "rest at lodging (if needed)".into(),
155 Self::Wait { wait_ticks } => format!("wait {wait_ticks}t"),
156 }
157 }
158
159 pub fn kind_label(&self) -> &'static str {
161 match self {
162 Self::Waypoint { .. } => "waypoint",
163 Self::HarvestNode { .. } => "harvest",
164 Self::DepositAt { .. } => "deposit",
165 Self::TradeWith { .. } => "sell",
166 Self::WithdrawFrom { .. } => "withdraw",
167 Self::CraftAt { .. } => "craft",
168 Self::CultivatePlot { .. } => "cultivate",
169 Self::PlantPlot { .. } => "plant",
170 Self::HarvestPlot { .. } => "harvest-plot",
171 Self::RestIfNeeded => "rest",
172 Self::Wait { .. } => "wait",
173 }
174 }
175}
176
177fn short_id(id: &str) -> String {
178 id.rsplit('-')
179 .next()
180 .filter(|s| !s.is_empty())
181 .unwrap_or(id)
182 .to_string()
183}
184
185fn short_plot_id(plot_id: &uuid::Uuid) -> String {
186 let s = plot_id.to_string();
187 s.get(..8).unwrap_or(s.as_str()).to_string()
188}
189
190pub fn list_filter_row_matches(filter: &str, dist_m: Option<f32>, fields: &[&str]) -> bool {
195 let tokens: Vec<&str> = filter
196 .split_whitespace()
197 .filter(|t| !t.is_empty())
198 .collect();
199 if tokens.is_empty() {
200 return true;
201 }
202 let hay: Vec<String> = fields.iter().map(|f| f.to_ascii_lowercase()).collect();
203 for tok in tokens {
204 let t = tok.to_ascii_lowercase();
205 if let Some(rest) = t.strip_suffix('m') {
206 if let Ok(max) = rest.parse::<f32>() {
207 if let Some(d) = dist_m {
208 if d > max {
209 return false;
210 }
211 continue;
212 }
213 }
214 }
215 if !hay.iter().any(|h| h.contains(&t)) {
216 return false;
217 }
218 }
219 true
220}
221
222#[derive(Debug, Clone, Copy, PartialEq)]
226pub enum WithdrawLineMode {
227 Off,
228 All,
229 Qty(u32),
230}
231
232#[derive(Debug, Clone, PartialEq)]
234pub struct WithdrawLineDraft {
235 pub template: String,
236 pub available: u32,
238 pub mode: WithdrawLineMode,
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub enum FarmPlotAction {
244 Cultivate,
245 Plant,
246 Harvest,
247}
248
249impl WithdrawLineDraft {
250 pub fn cycle(&mut self) {
252 self.mode = match self.mode {
253 WithdrawLineMode::Off => WithdrawLineMode::All,
254 WithdrawLineMode::All => WithdrawLineMode::Qty(self.available.clamp(1, 10)),
255 WithdrawLineMode::Qty(_) => WithdrawLineMode::Off,
256 };
257 }
258
259 pub fn adjust_qty(&mut self, delta: i32) {
261 let cur = match self.mode {
262 WithdrawLineMode::Off => self.available.clamp(1, 10),
263 WithdrawLineMode::All => self.available.clamp(1, 10),
264 WithdrawLineMode::Qty(q) => q,
265 };
266 let next = (cur as i32 + delta).clamp(1, self.available.max(1) as i32) as u32;
267 self.mode = WithdrawLineMode::Qty(next);
268 }
269}
270
271#[derive(Debug, Clone, PartialEq)]
273pub enum RouteEditorSheet {
274 Stops,
276 AddMenu { index: usize },
278 WaypointMenu { index: usize },
280 WaypointMapPick,
282 HarvestPicker {
285 index: usize,
286 picked: BTreeSet<String>,
287 nodes: Vec<NodeCandidate>,
288 },
289 WithdrawContainers { index: usize },
291 WithdrawItems {
293 container_id: String,
294 lines: Vec<WithdrawLineDraft>,
295 index: usize,
296 },
297 DepositContainers { index: usize },
299 DepositFilter {
301 container_id: String,
302 rows: Vec<(String, bool)>,
304 index: usize,
305 },
306 SellNpcs { index: usize },
308 SellItem {
310 npc_id: Option<String>,
311 templates: Vec<String>,
312 index: usize,
313 sell_all: bool,
314 picked: BTreeSet<String>,
315 },
316 CraftBlueprint { index: usize },
318 WaitEntry { ticks: u64 },
320 BedPicker { index: usize },
322 FarmPlotPicker {
324 index: usize,
325 action: FarmPlotAction,
326 },
327 FarmPlantSeed {
329 plot_id: uuid::Uuid,
330 seeds: Vec<String>,
331 index: usize,
332 },
333}
334
335#[derive(Debug, Clone, Copy, PartialEq)]
337pub enum RouteEditorClick {
338 SelectStop(usize),
340 OpenBedPicker,
342 SheetRow(usize),
344 TogglePanel,
346}
347
348pub const ADD_MENU: &[&str] = &[
350 "Waypoint",
351 "Harvest node",
352 "Withdraw from storage",
353 "Deposit to storage",
354 "Sell to merchant",
355 "Craft (at hand)",
356 "Rest if needed",
357 "Wait",
358 "Cultivate plot",
359 "Plant plot",
360 "Harvest plot",
361];
362
363pub const WAYPOINT_MENU: &[&str] = &["At player position", "Pick on map (click)"];
365
366#[derive(Debug, Clone, PartialEq)]
370pub struct ContainerCandidate {
371 pub id: String,
372 pub name: String,
373 pub is_lodging: bool,
374 pub summary: String,
376 pub dist: f32,
377}
378
379pub fn summarize_contents(contents: &[ItemStack]) -> String {
381 let mut totals: Vec<(String, u32)> = Vec::new();
382 for s in contents {
383 if s.template_id.is_empty() {
384 continue;
385 }
386 match totals.iter_mut().find(|(t, _)| t == &s.template_id) {
387 Some((_, q)) => *q += s.quantity,
388 None => totals.push((s.template_id.clone(), s.quantity)),
389 }
390 }
391 if totals.is_empty() {
392 return "(empty)".into();
393 }
394 totals.sort();
395 totals
396 .iter()
397 .map(|(t, q)| format!("{t} ×{q}"))
398 .collect::<Vec<_>>()
399 .join(" · ")
400}
401
402pub fn owned_container_candidates(
405 placed: &[PlacedContainerView],
406 character_id: Option<uuid::Uuid>,
407 px: f32,
408 py: f32,
409) -> Vec<ContainerCandidate> {
410 owned_container_candidates_with_occupants(placed, character_id, px, py, &[])
411}
412
413pub fn owned_container_candidates_with_occupants(
415 placed: &[PlacedContainerView],
416 character_id: Option<uuid::Uuid>,
417 px: f32,
418 py: f32,
419 hired: &[flatland_protocol::HiredWorkerView],
420) -> Vec<ContainerCandidate> {
421 owned_container_candidates_with_occupants_and_buildings(
422 placed,
423 &[],
424 character_id,
425 px,
426 py,
427 hired,
428 None,
429 )
430}
431
432pub fn owned_container_candidates_with_occupants_and_buildings(
434 placed: &[PlacedContainerView],
435 buildings: &[flatland_protocol::BuildingView],
436 character_id: Option<uuid::Uuid>,
437 px: f32,
438 py: f32,
439 hired: &[flatland_protocol::HiredWorkerView],
440 observer_inside: Option<&str>,
441) -> Vec<ContainerCandidate> {
442 let Some(cid) = character_id else {
443 return Vec::new();
444 };
445 let mut out: Vec<ContainerCandidate> = placed
446 .iter()
447 .filter(|c| c.owner_character_id == Some(cid))
448 .filter(|c| {
449 c.capacity_volume.unwrap_or(0.0) > 0.0 || c.worker_lodging_capacity.unwrap_or(0) > 0
450 })
451 .map(|c| {
452 let is_lodging = c.worker_lodging_capacity.unwrap_or(0) > 0;
453 let mut summary = summarize_contents(&c.contents);
454 if is_lodging {
455 let who = lodging_occupants_for(hired, &c.id);
456 let who = if who.is_empty() {
457 "vacant".into()
458 } else {
459 who.join(", ")
460 };
461 summary = format!("lodged: {who} · {summary}");
462 }
463 let cross_space = !container_in_observer_space(c, observer_inside);
464 let building = c
465 .building_id
466 .as_ref()
467 .and_then(|bid| buildings.iter().find(|b| &b.id == bid));
468 let (rx, ry) = if cross_space {
471 building
472 .map(|b| (b.x + b.width_m * 0.5, b.y + b.depth_m * 0.5))
473 .unwrap_or((c.x, c.y))
474 } else {
475 (c.x, c.y)
476 };
477 let name = if cross_space {
478 match building {
479 Some(b) if !b.label.is_empty() => {
480 format!("{} ({})", c.display_name, b.label)
481 }
482 Some(b) => format!("{} ({})", c.display_name, b.id),
483 None => c.display_name.clone(),
484 }
485 } else {
486 c.display_name.clone()
487 };
488 ContainerCandidate {
489 id: c.id.clone(),
490 name,
491 is_lodging,
492 summary,
493 dist: dist2d(px, py, rx, ry),
494 }
495 })
496 .collect();
497
498 for b in buildings {
499 if !b.tags.iter().any(|t| t.eq_ignore_ascii_case("storage")) {
500 continue;
501 }
502 let name = if b.label.is_empty() {
503 format!("Town storage ({})", b.id)
504 } else {
505 b.label.clone()
506 };
507 out.push(ContainerCandidate {
508 id: b.id.clone(),
509 name,
510 is_lodging: false,
511 summary: "(town vault)".into(),
512 dist: dist2d(px, py, b.x, b.y),
513 });
514 }
515
516 out.sort_by(|a, b| {
517 a.dist
518 .partial_cmp(&b.dist)
519 .unwrap_or(std::cmp::Ordering::Equal)
520 .then_with(|| a.name.cmp(&b.name))
521 .then_with(|| a.id.cmp(&b.id))
522 });
523 out
524}
525
526fn lodging_occupants_for(
527 hired: &[flatland_protocol::HiredWorkerView],
528 container_id: &str,
529) -> Vec<String> {
530 let mut names: Vec<String> = hired
531 .iter()
532 .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
533 .map(|w| w.label.clone())
534 .collect();
535 names.sort();
536 names
537}
538
539#[derive(Debug, Clone, PartialEq)]
541pub struct NodeCandidate {
542 pub id: String,
543 pub label: String,
544 pub template: String,
545 pub dist: f32,
546}
547
548pub fn node_candidates(
550 nodes: &[ResourceNodeView],
551 anchor_x: f32,
552 anchor_y: f32,
553) -> Vec<NodeCandidate> {
554 let mut out: Vec<NodeCandidate> = nodes
555 .iter()
556 .filter(|n| !n.harvest_off)
557 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
558 .map(|n| NodeCandidate {
559 id: n.id.clone(),
560 label: crate::resource_node_route_label(n),
561 template: n.item_template.clone(),
562 dist: dist2d(anchor_x, anchor_y, n.x, n.y),
563 })
564 .collect();
565 out.sort_by(|a, b| {
566 a.dist
567 .partial_cmp(&b.dist)
568 .unwrap_or(std::cmp::Ordering::Equal)
569 .then_with(|| a.label.cmp(&b.label))
570 .then_with(|| a.id.cmp(&b.id))
571 });
572 out
573}
574
575pub fn node_candidates_stable(nodes: &[ResourceNodeView]) -> Vec<NodeCandidate> {
577 let mut out: Vec<NodeCandidate> = nodes
578 .iter()
579 .filter(|n| !n.harvest_off)
580 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
581 .map(|n| NodeCandidate {
582 id: n.id.clone(),
583 label: crate::resource_node_route_label(n),
584 template: n.item_template.clone(),
585 dist: f32::NAN,
586 })
587 .collect();
588 out.sort_by(|a, b| a.label.cmp(&b.label).then_with(|| a.id.cmp(&b.id)));
589 out
590}
591
592pub fn route_editor_lodging_anchor(
594 lodging_container_id: Option<&str>,
595 placed: &[PlacedContainerView],
596) -> Option<(f32, f32)> {
597 let id = lodging_container_id?;
598 placed.iter().find(|c| c.id == id).map(|c| (c.x, c.y))
599}
600
601pub const ROUTE_PICKER_DONE_ROW: usize = 0;
603pub const SELL_ITEM_TOGGLE_ROW: usize = 1;
605
606pub fn harvest_picker_row_count(nodes_len: usize) -> usize {
607 nodes_len + 1
608}
609
610pub fn sell_item_picker_row_count(templates_len: usize) -> usize {
611 templates_len + 2
612}
613
614pub fn harvest_picker_row_matches(nodes: &[NodeCandidate], row: usize, filter: &str) -> bool {
616 if row == ROUTE_PICKER_DONE_ROW {
617 return true;
618 }
619 let slot = row - 1;
620 nodes.get(slot).is_some_and(|n| {
621 let dist = n.dist.is_finite().then_some(n.dist);
622 list_filter_row_matches(filter, dist, &[&n.label, &n.template, &n.id])
623 })
624}
625
626#[derive(Debug, Clone, PartialEq)]
628pub struct TradeNpcCandidate {
629 pub id: String,
630 pub label: String,
631 pub dist: f32,
632}
633
634pub fn trade_npc_candidates(npcs: &[NpcView], px: f32, py: f32) -> Vec<TradeNpcCandidate> {
636 let mut out: Vec<TradeNpcCandidate> = npcs
637 .iter()
638 .filter(|n| n.can_trade)
639 .map(|n| TradeNpcCandidate {
640 id: n.id.clone(),
641 label: n.label.clone(),
642 dist: dist2d(px, py, n.x, n.y),
643 })
644 .collect();
645 out.sort_by(|a, b| {
646 a.dist
647 .partial_cmp(&b.dist)
648 .unwrap_or(std::cmp::Ordering::Equal)
649 .then_with(|| a.id.cmp(&b.id))
650 });
651 out
652}
653
654#[derive(Debug, Clone)]
659pub struct WorkerRouteEditorState {
660 pub worker_instance_id: String,
661 pub worker_label: String,
662 pub lodging_container_id: Option<String>,
663 pub stops: Vec<WorkerRouteStop>,
665 pub selected_stop_index: usize,
667 pub carry_return_ratio: f32,
669 pub sheet: RouteEditorSheet,
671 pub editing_index: Option<usize>,
674 pub panel_collapsed: bool,
676 pub sheet_filter: String,
678 pub sheet_filter_focused: bool,
679}
680
681impl WorkerRouteEditorState {
682 pub fn new(
683 worker_instance_id: String,
684 worker_label: String,
685 lodging_container_id: Option<String>,
686 ) -> Self {
687 Self {
688 worker_instance_id,
689 worker_label,
690 lodging_container_id,
691 stops: Vec::new(),
692 selected_stop_index: 0,
693 carry_return_ratio: 0.90,
694 sheet: RouteEditorSheet::Stops,
695 editing_index: None,
696 panel_collapsed: false,
697 sheet_filter: String::new(),
698 sheet_filter_focused: false,
699 }
700 }
701
702 pub fn toggle_panel_collapsed(&mut self) {
703 self.panel_collapsed = !self.panel_collapsed;
704 }
705
706 pub fn from_saved_route(
707 worker_instance_id: String,
708 worker_label: String,
709 route: &WorkerRouteView,
710 lodging_fallback: Option<String>,
711 ) -> Self {
712 let lodging = route.lodging_container_id.clone().or(lodging_fallback);
713
714 match route.kind {
715 WorkerRouteKindView::Ordered => Self {
716 worker_instance_id,
717 worker_label,
718 lodging_container_id: lodging,
719 stops: route.stops.iter().map(stop_view_to_stop).collect(),
720 selected_stop_index: 0,
721 carry_return_ratio: route.carry_return_ratio,
722 sheet: RouteEditorSheet::Stops,
723 editing_index: None,
724 panel_collapsed: false,
725 sheet_filter: String::new(),
726 sheet_filter_focused: false,
727 },
728 WorkerRouteKindView::HarvestLoop => {
729 let mut stops = Vec::new();
734 for wp in &route.outbound_waypoints {
735 stops.push(WorkerRouteStop::Waypoint {
736 x: wp.x,
737 y: wp.y,
738 z: wp.z,
739 });
740 }
741 for node in &route.harvest_nodes {
742 stops.push(WorkerRouteStop::HarvestNode {
743 node_id: node.clone(),
744 });
745 }
746 if let Some(lodging) = &lodging {
747 stops.push(WorkerRouteStop::DepositAt {
748 container_id: lodging.clone(),
749 filter: None,
750 });
751 stops.push(WorkerRouteStop::RestIfNeeded);
752 }
753 Self {
754 worker_instance_id,
755 worker_label,
756 lodging_container_id: lodging,
757 stops,
758 selected_stop_index: 0,
759 carry_return_ratio: route.carry_return_ratio,
760 sheet: RouteEditorSheet::Stops,
761 editing_index: None,
762 panel_collapsed: false,
763 sheet_filter: String::new(),
764 sheet_filter_focused: false,
765 }
766 }
767 }
768 }
769
770 pub fn stop_count(&self) -> usize {
773 self.stops.len()
774 }
775
776 pub fn select_stop(&mut self, index: usize) {
777 if self.stops.is_empty() {
778 self.selected_stop_index = 0;
779 return;
780 }
781 self.selected_stop_index = index.min(self.stops.len() - 1);
782 }
783
784 pub fn move_selected_up(&mut self) {
785 if self.selected_stop_index == 0 {
786 return;
787 }
788 self.stops
789 .swap(self.selected_stop_index, self.selected_stop_index - 1);
790 self.selected_stop_index -= 1;
791 }
792
793 pub fn move_selected_down(&mut self) {
794 if self.selected_stop_index + 1 >= self.stops.len() {
795 return;
796 }
797 self.stops
798 .swap(self.selected_stop_index, self.selected_stop_index + 1);
799 self.selected_stop_index += 1;
800 }
801
802 pub fn remove_selected_stop(&mut self) {
803 if self.stops.is_empty() {
804 return;
805 }
806 let idx = self.selected_stop_index.min(self.stops.len() - 1);
807 self.stops.remove(idx);
808 self.editing_index = None;
809 if self.selected_stop_index >= self.stops.len() {
810 self.selected_stop_index = self.stops.len().saturating_sub(1);
811 }
812 }
813
814 fn find_stop(&self, pred: impl Fn(&WorkerRouteStop) -> bool) -> Option<usize> {
816 self.stops.iter().position(pred)
817 }
818
819 pub fn harvest_node_index(&self, node_id: &str) -> Option<usize> {
820 self.find_stop(|s| matches!(s, WorkerRouteStop::HarvestNode { node_id: n } if n == node_id))
821 }
822
823 pub fn deposit_container_index(&self, container_id: &str) -> Option<usize> {
824 self.find_stop(
825 |s| matches!(s, WorkerRouteStop::DepositAt { container_id: c, .. } if c == container_id),
826 )
827 }
828
829 pub fn trade_stop_index(&self, npc_id: Option<&str>, template: &str) -> Option<usize> {
830 self.find_stop(|s| {
831 matches!(s, WorkerRouteStop::TradeWith { npc_id: n, template: t, .. }
832 if n.as_deref() == npc_id && t == template)
833 })
834 }
835
836 pub fn insert_stop(&mut self, stop: WorkerRouteStop) -> (bool, usize) {
840 let existing = match &stop {
841 WorkerRouteStop::HarvestNode { node_id } => self.harvest_node_index(node_id),
842 WorkerRouteStop::DepositAt { container_id, .. } => {
843 self.deposit_container_index(container_id)
844 }
845 WorkerRouteStop::TradeWith {
846 npc_id, template, ..
847 } => self.trade_stop_index(npc_id.as_deref(), template),
848 _ => None,
849 };
850 if let Some(idx) = existing {
851 self.selected_stop_index = idx;
852 return (false, idx);
853 }
854 self.stops.push(stop);
855 self.selected_stop_index = self.stops.len() - 1;
856 (true, self.stops.len() - 1)
857 }
858
859 pub fn append_waypoint(&mut self, x: f32, y: f32, z: f32) {
860 self.insert_stop(WorkerRouteStop::Waypoint { x, y, z });
861 }
862
863 pub fn append_harvest_node(&mut self, node_id: &str) -> bool {
864 self.insert_stop(WorkerRouteStop::HarvestNode {
865 node_id: node_id.to_string(),
866 })
867 .0
868 }
869
870 pub fn append_deposit_at(&mut self, container_id: &str) -> bool {
871 self.insert_stop(WorkerRouteStop::DepositAt {
872 container_id: container_id.to_string(),
873 filter: None,
874 })
875 .0
876 }
877
878 pub fn append_deposit_at_filtered(
881 &mut self,
882 container_id: &str,
883 filter_templates: Vec<String>,
884 ) {
885 self.stops.push(WorkerRouteStop::DepositAt {
886 container_id: container_id.to_string(),
887 filter: Some(filter_templates),
888 });
889 self.selected_stop_index = self.stops.len() - 1;
890 }
891
892 pub fn append_rest_if_needed(&mut self) {
893 self.insert_stop(WorkerRouteStop::RestIfNeeded);
894 }
895
896 pub fn append_wait(&mut self, wait_ticks: u64) {
897 self.insert_stop(WorkerRouteStop::Wait { wait_ticks });
898 }
899
900 pub fn append_trade_with(
904 &mut self,
905 template: String,
906 npc_id: Option<String>,
907 sell_all: bool,
908 ) -> bool {
909 self.insert_stop(WorkerRouteStop::TradeWith {
910 npc_id,
911 template,
912 sell_all,
913 })
914 .0
915 }
916
917 pub fn set_selected_trade_npc(&mut self, npc_id: String) -> bool {
922 let Some(stop) = self.stops.get_mut(self.selected_stop_index) else {
923 return false;
924 };
925 let WorkerRouteStop::TradeWith {
926 npc_id: slot,
927 template,
928 ..
929 } = stop
930 else {
931 return false;
932 };
933 *slot = Some(npc_id.clone());
934 let template = template.clone();
935 let selected = self.selected_stop_index;
936 if let Some(other) = self
937 .trade_stop_index(Some(npc_id.as_str()), template.as_str())
938 .filter(|&i| i != selected)
939 {
940 self.stops.remove(selected);
941 self.selected_stop_index = if other > selected { other - 1 } else { other };
942 }
943 true
944 }
945
946 pub fn set_selected_withdraw_container(&mut self, container_id: String) -> bool {
950 let Some(stop) = self.stops.get_mut(self.selected_stop_index) else {
951 return false;
952 };
953 if let WorkerRouteStop::WithdrawFrom {
954 container_id: slot, ..
955 } = stop
956 {
957 *slot = container_id;
958 return true;
959 }
960 false
961 }
962
963 pub fn retarget_withdraw_container(&mut self, container_id: String) -> bool {
966 let idx = self.editing_index.unwrap_or(self.selected_stop_index);
967 let Some(stop) = self.stops.get_mut(idx) else {
968 return false;
969 };
970 if let WorkerRouteStop::WithdrawFrom {
971 container_id: slot, ..
972 } = stop
973 {
974 *slot = container_id;
975 return true;
976 }
977 false
978 }
979
980 pub fn retarget_deposit_container(&mut self, container_id: String) -> bool {
983 let idx = self.editing_index.unwrap_or(self.selected_stop_index);
984 let Some(stop) = self.stops.get_mut(idx) else {
985 return false;
986 };
987 if let WorkerRouteStop::DepositAt {
988 container_id: slot, ..
989 } = stop
990 {
991 *slot = container_id;
992 return true;
993 }
994 false
995 }
996
997 pub fn open_add_menu(&mut self) {
1000 self.editing_index = None;
1001 self.sheet = RouteEditorSheet::AddMenu { index: 0 };
1002 }
1003
1004 pub fn open_sheet(&mut self, sheet: RouteEditorSheet) {
1005 self.sheet_filter.clear();
1006 self.sheet_filter_focused = false;
1007 self.sheet = sheet;
1008 }
1009
1010 pub fn confirm_harvest_picks(&mut self, node_ids: &[String]) -> usize {
1012 if node_ids.is_empty() {
1013 return 0;
1014 }
1015 let mut added = 0usize;
1016 if let Some(idx) = self.editing_index.take() {
1017 if let Some(first) = node_ids.first() {
1018 if idx < self.stops.len() {
1019 self.stops[idx] = WorkerRouteStop::HarvestNode {
1020 node_id: first.clone(),
1021 };
1022 self.selected_stop_index = idx;
1023 added = 1;
1024 }
1025 for id in node_ids.iter().skip(1) {
1026 if self
1027 .insert_stop(WorkerRouteStop::HarvestNode {
1028 node_id: id.clone(),
1029 })
1030 .0
1031 {
1032 added += 1;
1033 }
1034 }
1035 }
1036 } else {
1037 for id in node_ids {
1038 if self
1039 .insert_stop(WorkerRouteStop::HarvestNode {
1040 node_id: id.clone(),
1041 })
1042 .0
1043 {
1044 added += 1;
1045 }
1046 }
1047 }
1048 self.sheet = RouteEditorSheet::Stops;
1049 added
1050 }
1051
1052 pub fn confirm_trade_picks(
1054 &mut self,
1055 npc_id: Option<String>,
1056 templates: &[String],
1057 sell_all: bool,
1058 ) -> usize {
1059 if templates.is_empty() {
1060 return 0;
1061 }
1062 let mut added = 0usize;
1063 if let Some(idx) = self.editing_index.take() {
1064 if let Some(first) = templates.first() {
1065 if idx < self.stops.len() {
1066 self.stops[idx] = WorkerRouteStop::TradeWith {
1067 npc_id: npc_id.clone(),
1068 template: first.clone(),
1069 sell_all,
1070 };
1071 self.selected_stop_index = idx;
1072 added = 1;
1073 }
1074 for template in templates.iter().skip(1) {
1075 if self
1076 .insert_stop(WorkerRouteStop::TradeWith {
1077 npc_id: npc_id.clone(),
1078 template: template.clone(),
1079 sell_all,
1080 })
1081 .0
1082 {
1083 added += 1;
1084 }
1085 }
1086 }
1087 } else {
1088 for template in templates {
1089 if self
1090 .insert_stop(WorkerRouteStop::TradeWith {
1091 npc_id: npc_id.clone(),
1092 template: template.clone(),
1093 sell_all,
1094 })
1095 .0
1096 {
1097 added += 1;
1098 }
1099 }
1100 }
1101 self.sheet = RouteEditorSheet::Stops;
1102 added
1103 }
1104
1105 pub fn begin_edit_selected(&mut self) {
1108 if self.selected_stop_index < self.stops.len() {
1109 self.editing_index = Some(self.selected_stop_index);
1110 }
1111 }
1112
1113 pub fn sheet_back(&mut self) {
1117 use RouteEditorSheet as S;
1118 let editing = self.editing_index.is_some();
1119 let next = match &self.sheet {
1120 S::Stops => return,
1121 S::AddMenu { .. } | S::BedPicker { .. } | S::FarmPlotPicker { .. } => S::Stops,
1122 S::FarmPlantSeed { .. } => S::FarmPlotPicker {
1123 index: 0,
1124 action: FarmPlotAction::Plant,
1125 },
1126 S::WaypointMapPick => {
1127 if editing {
1128 S::Stops
1129 } else {
1130 S::WaypointMenu { index: 0 }
1131 }
1132 }
1133 S::WithdrawItems { .. } => S::WithdrawContainers { index: 0 },
1136 S::DepositFilter { .. } => S::DepositContainers { index: 0 },
1137 S::SellItem { .. } => S::SellNpcs { index: 0 },
1138 S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
1139 if editing =>
1140 {
1141 S::Stops
1142 }
1143 _ => {
1145 if editing {
1146 S::Stops
1147 } else {
1148 S::AddMenu { index: 0 }
1149 }
1150 }
1151 };
1152 if matches!(next, S::Stops) {
1153 self.editing_index = None;
1154 }
1155 self.sheet = next;
1156 }
1157
1158 pub fn confirm_stop(&mut self, stop: WorkerRouteStop) -> bool {
1162 let result = if let Some(idx) = self.editing_index.take() {
1163 if idx < self.stops.len() {
1164 self.stops[idx] = stop;
1165 self.selected_stop_index = idx;
1166 }
1167 true
1168 } else {
1169 self.insert_stop(stop).0
1170 };
1171 self.sheet = RouteEditorSheet::Stops;
1172 result
1173 }
1174
1175 pub fn withdraw_line_drafts(
1179 contents: &[ItemStack],
1180 existing: &[WorkerRouteWithdrawItem],
1181 ) -> Vec<WithdrawLineDraft> {
1182 let mut lines: Vec<WithdrawLineDraft> = Vec::new();
1183 for s in contents {
1184 if s.template_id.is_empty() {
1185 continue;
1186 }
1187 match lines.iter_mut().find(|l| l.template == s.template_id) {
1188 Some(l) => l.available += s.quantity,
1189 None => lines.push(WithdrawLineDraft {
1190 template: s.template_id.clone(),
1191 available: s.quantity,
1192 mode: WithdrawLineMode::Off,
1193 }),
1194 }
1195 }
1196 for item in existing {
1197 let mode = match item.qty {
1198 None => WithdrawLineMode::All,
1199 Some(q) => WithdrawLineMode::Qty(q),
1200 };
1201 match lines.iter_mut().find(|l| l.template == item.template) {
1202 Some(l) => l.mode = mode,
1203 None => lines.push(WithdrawLineDraft {
1204 template: item.template.clone(),
1205 available: 0,
1206 mode,
1207 }),
1208 }
1209 }
1210 lines.sort_by(|a, b| a.template.cmp(&b.template));
1211 lines
1212 }
1213
1214 pub fn withdraw_items_from_lines(lines: &[WithdrawLineDraft]) -> Vec<WorkerRouteWithdrawItem> {
1216 lines
1217 .iter()
1218 .filter_map(|l| match l.mode {
1219 WithdrawLineMode::Off => None,
1220 WithdrawLineMode::All => Some(WorkerRouteWithdrawItem {
1221 template: l.template.clone(),
1222 qty: None,
1223 }),
1224 WithdrawLineMode::Qty(q) => Some(WorkerRouteWithdrawItem {
1225 template: l.template.clone(),
1226 qty: Some(q),
1227 }),
1228 })
1229 .collect()
1230 }
1231
1232 fn job_id(&self) -> String {
1235 format!(
1236 "route_{}",
1237 self.worker_instance_id
1238 .chars()
1239 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
1240 .collect::<String>()
1241 )
1242 }
1243
1244 pub fn build_idle_job_yaml(&self) -> String {
1248 let job_id = self.job_id();
1251 [
1252 format!("job_id: {job_id}"),
1253 "mode: idle".into(),
1254 "steps: []".into(),
1255 ]
1256 .join("\n")
1257 }
1258
1259 pub fn build_job_yaml(&self) -> Result<String, String> {
1260 if self.stops.is_empty() {
1261 return Err("add at least one stop (waypoint, harvest node, or deposit)".into());
1262 }
1263 let job_id = self.job_id();
1264 let mut lines = vec![
1265 format!("job_id: {job_id}"),
1266 "mode: job_loop".into(),
1267 "route:".into(),
1268 " kind: ordered".into(),
1269 ];
1270 if let Some(lodging) = &self.lodging_container_id {
1271 lines.push(format!(" lodging_container_id: {lodging}"));
1272 }
1273 lines.push(format!(
1274 " carry_return_ratio: {:.2}",
1275 self.carry_return_ratio
1276 ));
1277 lines.push(" stops:".into());
1278 for stop in &self.stops {
1279 match stop {
1280 WorkerRouteStop::Waypoint { x, y, z } => {
1281 lines.push(format!(
1282 " - {{ stop: waypoint, x: {:.1}, y: {:.1}, z: {:.1} }}",
1283 x, y, z
1284 ));
1285 }
1286 WorkerRouteStop::HarvestNode { node_id } => {
1287 lines.push(format!(
1288 " - {{ stop: harvest_node, node_id: {node_id} }}"
1289 ));
1290 }
1291 WorkerRouteStop::DepositAt {
1292 container_id,
1293 filter,
1294 } => {
1295 let f = filter
1296 .as_ref()
1297 .filter(|f| !f.is_empty())
1298 .map(|f| {
1299 format!(
1300 ", filter: [{}]",
1301 f.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
1302 )
1303 })
1304 .unwrap_or_default();
1305 lines.push(format!(
1306 " - {{ stop: deposit_at, container_id: {container_id}{f} }}"
1307 ));
1308 }
1309 WorkerRouteStop::RestIfNeeded => {
1310 lines.push(" - { stop: rest_if_needed }".into());
1311 }
1312 WorkerRouteStop::Wait { wait_ticks } => {
1313 lines.push(format!(" - {{ stop: wait, wait_ticks: {wait_ticks} }}"));
1314 }
1315 WorkerRouteStop::TradeWith {
1316 npc_id,
1317 template,
1318 sell_all,
1319 } => {
1320 let who = npc_id
1321 .as_deref()
1322 .map(|n| format!(", npc_id: {n}"))
1323 .unwrap_or_default();
1324 lines.push(format!(
1325 " - {{ stop: trade_with, template: {template}{who}, sell_all: {sell_all} }}"
1326 ));
1327 }
1328 WorkerRouteStop::WithdrawFrom {
1329 container_id,
1330 items,
1331 } => {
1332 let mut block = format!(
1333 " - stop: withdraw_from\n container_id: {container_id}\n items:"
1334 );
1335 for it in items {
1336 let line = match it.qty {
1337 None => {
1338 format!("\n - {{ template: {}, all: true }}", it.template)
1339 }
1340 Some(q) => {
1341 format!("\n - {{ template: {}, qty: {} }}", it.template, q)
1342 }
1343 };
1344 block.push_str(&line);
1345 }
1346 lines.push(block);
1347 }
1348 WorkerRouteStop::CraftAt {
1349 device,
1350 blueprint,
1351 qty,
1352 } => {
1353 let qty_str = qty.map(|q| format!(", qty: {q}")).unwrap_or_default();
1354 lines.push(format!(
1355 " - {{ stop: craft_at, device: {device}, blueprint: {blueprint}{qty_str} }}"
1356 ));
1357 }
1358 WorkerRouteStop::CultivatePlot { plot_id } => {
1359 lines.push(format!(
1360 " - {{ stop: cultivate_plot, plot_id: \"{plot_id}\" }}"
1361 ));
1362 }
1363 WorkerRouteStop::PlantPlot {
1364 plot_id,
1365 seed_template,
1366 } => {
1367 lines.push(format!(
1368 " - {{ stop: plant_plot, plot_id: \"{plot_id}\", seed_template: {seed_template} }}"
1369 ));
1370 }
1371 WorkerRouteStop::HarvestPlot { plot_id } => {
1372 lines.push(format!(
1373 " - {{ stop: harvest_plot, plot_id: \"{plot_id}\" }}"
1374 ));
1375 }
1376 }
1377 }
1378 lines.push("steps: []".into());
1379 Ok(lines.join("\n"))
1380 }
1381
1382 pub fn to_route_view(&self) -> flatland_protocol::WorkerRouteView {
1384 use flatland_protocol::{
1385 WorkerRouteKindView, WorkerRouteStopView, WorkerRouteView, WorkerWithdrawItemView,
1386 };
1387 WorkerRouteView {
1388 kind: WorkerRouteKindView::Ordered,
1389 lodging_container_id: self.lodging_container_id.clone(),
1390 outbound_waypoints: Vec::new(),
1391 harvest_nodes: Vec::new(),
1392 carry_return_ratio: self.carry_return_ratio,
1393 stops: self
1394 .stops
1395 .iter()
1396 .map(|stop| match stop {
1397 WorkerRouteStop::Waypoint { x, y, z } => WorkerRouteStopView::Waypoint {
1398 x: *x,
1399 y: *y,
1400 z: *z,
1401 },
1402 WorkerRouteStop::HarvestNode { node_id } => WorkerRouteStopView::HarvestNode {
1403 node_id: node_id.clone(),
1404 },
1405 WorkerRouteStop::DepositAt {
1406 container_id,
1407 filter,
1408 } => WorkerRouteStopView::DepositAt {
1409 container_id: container_id.clone(),
1410 filter: filter.clone(),
1411 },
1412 WorkerRouteStop::TradeWith {
1413 npc_id,
1414 template,
1415 sell_all,
1416 } => WorkerRouteStopView::TradeWith {
1417 npc_id: npc_id.clone(),
1418 template: template.clone(),
1419 sell_all: *sell_all,
1420 },
1421 WorkerRouteStop::WithdrawFrom {
1422 container_id,
1423 items,
1424 } => WorkerRouteStopView::WithdrawFrom {
1425 container_id: container_id.clone(),
1426 items: items
1427 .iter()
1428 .map(|i| WorkerWithdrawItemView {
1429 template: i.template.clone(),
1430 qty: i.qty.unwrap_or(0),
1431 all: i.qty.is_none(),
1432 })
1433 .collect(),
1434 },
1435 WorkerRouteStop::CraftAt {
1436 device,
1437 blueprint,
1438 qty,
1439 } => WorkerRouteStopView::CraftAt {
1440 device: device.clone(),
1441 blueprint: blueprint.clone(),
1442 qty: *qty,
1443 },
1444 WorkerRouteStop::CultivatePlot { plot_id } => {
1445 WorkerRouteStopView::CultivatePlot { plot_id: *plot_id }
1446 }
1447 WorkerRouteStop::PlantPlot {
1448 plot_id,
1449 seed_template,
1450 } => WorkerRouteStopView::PlantPlot {
1451 plot_id: *plot_id,
1452 seed_template: seed_template.clone(),
1453 },
1454 WorkerRouteStop::HarvestPlot { plot_id } => {
1455 WorkerRouteStopView::HarvestPlot { plot_id: *plot_id }
1456 }
1457 WorkerRouteStop::RestIfNeeded => WorkerRouteStopView::RestIfNeeded,
1458 WorkerRouteStop::Wait { wait_ticks } => WorkerRouteStopView::Wait {
1459 wait_ticks: *wait_ticks,
1460 },
1461 })
1462 .collect(),
1463 }
1464 }
1465}
1466
1467fn stop_view_to_stop(view: &WorkerRouteStopView) -> WorkerRouteStop {
1468 match view {
1469 WorkerRouteStopView::Waypoint { x, y, z } => WorkerRouteStop::Waypoint {
1470 x: *x,
1471 y: *y,
1472 z: *z,
1473 },
1474 WorkerRouteStopView::HarvestNode { node_id } => WorkerRouteStop::HarvestNode {
1475 node_id: node_id.clone(),
1476 },
1477 WorkerRouteStopView::DepositAt {
1478 container_id,
1479 filter,
1480 } => WorkerRouteStop::DepositAt {
1481 container_id: container_id.clone(),
1482 filter: filter.clone(),
1483 },
1484 WorkerRouteStopView::TradeWith {
1485 npc_id,
1486 template,
1487 sell_all,
1488 } => WorkerRouteStop::TradeWith {
1489 npc_id: npc_id.clone(),
1490 template: template.clone(),
1491 sell_all: *sell_all,
1492 },
1493 WorkerRouteStopView::WithdrawFrom {
1494 container_id,
1495 items,
1496 } => WorkerRouteStop::WithdrawFrom {
1497 container_id: container_id.clone(),
1498 items: items
1499 .iter()
1500 .map(|i| WorkerRouteWithdrawItem {
1501 template: i.template.clone(),
1502 qty: if i.all { None } else { Some(i.qty) },
1503 })
1504 .collect(),
1505 },
1506 WorkerRouteStopView::CraftAt {
1507 device,
1508 blueprint,
1509 qty,
1510 } => WorkerRouteStop::CraftAt {
1511 device: device.clone(),
1512 blueprint: blueprint.clone(),
1513 qty: *qty,
1514 },
1515 WorkerRouteStopView::CultivatePlot { plot_id } => {
1516 WorkerRouteStop::CultivatePlot { plot_id: *plot_id }
1517 }
1518 WorkerRouteStopView::PlantPlot {
1519 plot_id,
1520 seed_template,
1521 } => WorkerRouteStop::PlantPlot {
1522 plot_id: *plot_id,
1523 seed_template: seed_template.clone(),
1524 },
1525 WorkerRouteStopView::HarvestPlot { plot_id } => {
1526 WorkerRouteStop::HarvestPlot { plot_id: *plot_id }
1527 }
1528 WorkerRouteStopView::RestIfNeeded => WorkerRouteStop::RestIfNeeded,
1529 WorkerRouteStopView::Wait { wait_ticks } => WorkerRouteStop::Wait {
1530 wait_ticks: *wait_ticks,
1531 },
1532 }
1533}
1534
1535const HARVEST_NODE_PICK_M: f32 = 4.0;
1538const LODGING_PICK_M: f32 = 5.0;
1539const STORAGE_PICK_M: f32 = 5.0;
1540
1541fn dist2d(x0: f32, y0: f32, x1: f32, y1: f32) -> f32 {
1542 let dx = x0 - x1;
1543 let dy = y0 - y1;
1544 (dx * dx + dy * dy).sqrt()
1545}
1546
1547pub fn pick_resource_node_at<'a>(
1549 nodes: &'a [ResourceNodeView],
1550 x: f32,
1551 y: f32,
1552) -> Option<&'a ResourceNodeView> {
1553 nodes
1554 .iter()
1555 .filter(|n| !n.harvest_off)
1556 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
1557 .filter_map(|n| {
1558 let d = dist2d(x, y, n.x, n.y);
1559 if d <= HARVEST_NODE_PICK_M {
1560 Some((d, n))
1561 } else {
1562 None
1563 }
1564 })
1565 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1566 .map(|(_, n)| n)
1567}
1568
1569pub fn owned_lodging_container_ids(
1571 placed: &[PlacedContainerView],
1572 character_id: Option<uuid::Uuid>,
1573) -> Vec<(String, String)> {
1574 owned_lodging_container_ids_with_occupants(placed, character_id, &[])
1575}
1576
1577pub fn owned_lodging_container_ids_with_occupants(
1579 placed: &[PlacedContainerView],
1580 character_id: Option<uuid::Uuid>,
1581 hired: &[flatland_protocol::HiredWorkerView],
1582) -> Vec<(String, String)> {
1583 let Some(cid) = character_id else {
1584 return Vec::new();
1585 };
1586 let mut out: Vec<(String, String)> = placed
1587 .iter()
1588 .filter(|c| c.worker_lodging_capacity.unwrap_or(0) > 0)
1589 .filter(|c| c.owner_character_id == Some(cid))
1590 .map(|c| {
1591 let who = lodging_occupants_for(hired, &c.id);
1592 let name = if who.is_empty() {
1593 format!("{} — vacant", c.display_name)
1594 } else {
1595 format!("{} — {}", c.display_name, who.join(", "))
1596 };
1597 (c.id.clone(), name)
1598 })
1599 .collect();
1600 out.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
1601 out
1602}
1603
1604pub fn pick_lodging_container_at(
1605 placed: &[PlacedContainerView],
1606 character_id: Option<uuid::Uuid>,
1607 x: f32,
1608 y: f32,
1609 observer_inside: Option<&str>,
1610) -> Option<String> {
1611 let cid = character_id?;
1612 placed
1613 .iter()
1614 .filter(|c| container_in_observer_space(c, observer_inside))
1615 .filter(|c| c.worker_lodging_capacity.unwrap_or(0) > 0)
1616 .filter(|c| c.owner_character_id == Some(cid))
1617 .filter_map(|c| {
1618 let d = dist2d(x, y, c.x, c.y);
1619 if d <= LODGING_PICK_M {
1620 Some((d, c.id.clone()))
1621 } else {
1622 None
1623 }
1624 })
1625 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1626 .map(|(_, id)| id)
1627}
1628
1629pub fn pick_trade_npc_at(npcs: &[NpcView], x: f32, y: f32) -> Option<(String, String)> {
1631 const NPC_PICK_M: f32 = 5.0;
1632 npcs.iter()
1633 .filter(|n| n.can_trade)
1634 .filter_map(|n| {
1635 let d = dist2d(x, y, n.x, n.y);
1636 if d <= NPC_PICK_M {
1637 Some((d, n.id.clone(), n.label.clone()))
1638 } else {
1639 None
1640 }
1641 })
1642 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1643 .map(|(_, id, label)| (id, label))
1644}
1645
1646pub fn owned_storage_template_ids(
1649 placed: &[PlacedContainerView],
1650 character_id: Option<uuid::Uuid>,
1651) -> Vec<String> {
1652 let Some(cid) = character_id else {
1653 return Vec::new();
1654 };
1655 let mut out: Vec<String> = placed
1656 .iter()
1657 .filter(|c| c.owner_character_id == Some(cid))
1658 .flat_map(|c| c.contents.iter().map(|s| s.template_id.clone()))
1659 .filter(|t| !t.is_empty())
1660 .collect();
1661 out.sort();
1662 out.dedup();
1663 out
1664}
1665
1666pub fn worker_craft_blueprint_ids(
1673 blueprints: &[flatland_protocol::BlueprintView],
1674 known_blueprint_ids: Option<&[String]>,
1675) -> Vec<String> {
1676 let mut ids: Vec<String> = blueprints.iter().map(|b| b.id.clone()).collect();
1677 if let Some(known) = known_blueprint_ids {
1678 if !known.is_empty() {
1679 ids.retain(|id| known.iter().any(|k| k == id));
1680 }
1681 }
1682 ids
1683}
1684
1685pub fn route_item_template_candidates(
1691 placed: &[PlacedContainerView],
1692 character_id: Option<uuid::Uuid>,
1693 inventory: &std::collections::HashMap<String, u32>,
1694 blueprints: &[flatland_protocol::BlueprintView],
1695 resource_nodes: &[flatland_protocol::ResourceNodeView],
1696 extra: &[String],
1697 catalog: Option<&std::collections::HashMap<String, ItemCatalogEntryView>>,
1698) -> Vec<String> {
1699 let mut out = owned_storage_template_ids(placed, character_id);
1700 for (template, qty) in inventory {
1701 if *qty > 0 && !template.is_empty() {
1702 out.push(template.clone());
1703 }
1704 }
1705 for bp in blueprints {
1706 if !bp.output.is_empty() {
1707 out.push(bp.output.clone());
1708 }
1709 for input in &bp.inputs {
1710 if !input.template_id.is_empty() {
1711 out.push(input.template_id.clone());
1712 }
1713 }
1714 for tool in &bp.required_tools {
1715 if !tool.item.is_empty() {
1716 out.push(tool.item.clone());
1717 }
1718 }
1719 }
1720 for node in resource_nodes {
1721 for t in &node.harvest_drop_templates {
1723 if !t.is_empty() {
1724 out.push(t.clone());
1725 }
1726 }
1727 }
1728 for t in extra {
1729 if !t.is_empty() {
1730 out.push(t.clone());
1731 }
1732 }
1733 out.sort();
1734 out.dedup();
1735 if let Some(catalog) = catalog {
1736 out.retain(|id| catalog.get(id).is_none_or(|e| e.is_depositable_stack()));
1737 }
1738 out
1739}
1740
1741pub fn sellable_route_item_template_candidates(
1747 templates: &[String],
1748 npcs: &[flatland_protocol::NpcView],
1749 npc_id: Option<&str>,
1750) -> Vec<String> {
1751 let accepted = npcs
1752 .iter()
1753 .filter(|npc| npc_id.is_none_or(|id| npc.id == id))
1754 .filter(|npc| npc_id.is_some() || npc.can_trade)
1755 .flat_map(|npc| npc.buy_templates.iter().map(String::as_str))
1756 .collect::<std::collections::HashSet<_>>();
1757 let mut out: Vec<String> = templates
1758 .iter()
1759 .filter(|template| accepted.contains(template.as_str()))
1760 .cloned()
1761 .collect();
1762 out.sort();
1763 out.dedup();
1764 out
1765}
1766
1767pub fn pick_storage_container_at(
1771 placed: &[PlacedContainerView],
1772 character_id: Option<uuid::Uuid>,
1773 x: f32,
1774 y: f32,
1775 observer_inside: Option<&str>,
1776) -> Option<String> {
1777 let cid = character_id?;
1778 placed
1779 .iter()
1780 .filter(|c| container_in_observer_space(c, observer_inside))
1781 .filter(|c| c.owner_character_id == Some(cid))
1782 .filter(|c| c.capacity_volume.unwrap_or(0.0) > 0.0)
1783 .filter_map(|c| {
1784 let d = dist2d(x, y, c.x, c.y);
1785 if d <= STORAGE_PICK_M {
1786 Some((d, c.id.clone()))
1787 } else {
1788 None
1789 }
1790 })
1791 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1792 .map(|(_, id)| id)
1793}
1794
1795fn container_in_observer_space(c: &PlacedContainerView, observer_inside: Option<&str>) -> bool {
1796 match (observer_inside, c.building_id.as_deref()) {
1797 (None, None) => true,
1798 (Some(a), Some(b)) => a == b,
1799 _ => false,
1800 }
1801}
1802
1803#[cfg(test)]
1804mod tests {
1805 use super::*;
1806
1807 #[test]
1808 fn worker_craft_blueprint_ids_filters_to_known_recipes() {
1809 use flatland_protocol::{BlueprintIngredientView, BlueprintView};
1810
1811 fn bp(id: &str) -> BlueprintView {
1812 BlueprintView {
1813 id: id.into(),
1814 label: id.into(),
1815 output: "x".into(),
1816 output_qty: 1,
1817 craft_ticks: 1,
1818 inputs: vec![BlueprintIngredientView {
1819 template_id: "oak_log".into(),
1820 quantity: 1,
1821 consumed: true,
1822 display_name: "Oak Log".into(),
1823 }],
1824 station: Some("hand".into()),
1825 category: None,
1826 required_tools: vec![],
1827 skill: None,
1828 failure_chance: 0.0,
1829 worker_train_copper: 0,
1830 output_display_name: "X".into(),
1831 craft_tier: 1,
1832 }
1833 }
1834
1835 let all = vec![
1836 bp("oak_to_lumber"),
1837 bp("vegetable_soup"),
1838 bp("craft_simple_camp_bed"),
1839 bp("craft_wooden_chest_small"),
1840 bp("iron_ingot"),
1841 ];
1842 let known = vec![
1843 "oak_to_lumber".into(),
1844 "craft_simple_camp_bed".into(),
1845 "craft_wooden_chest_small".into(),
1846 ];
1847
1848 let filtered = worker_craft_blueprint_ids(&all, Some(&known));
1849 assert_eq!(
1850 filtered,
1851 vec![
1852 "oak_to_lumber",
1853 "craft_simple_camp_bed",
1854 "craft_wooden_chest_small"
1855 ]
1856 );
1857
1858 assert_eq!(worker_craft_blueprint_ids(&all, Some(&[])).len(), all.len());
1860 assert_eq!(worker_craft_blueprint_ids(&all, None).len(), all.len());
1861 }
1862
1863 #[test]
1864 fn route_item_candidates_include_craft_outputs_not_in_storage() {
1865 use flatland_protocol::{
1866 BlueprintIngredientView, BlueprintView, PlacedContainerView, ResourceNodeState,
1867 ResourceNodeView,
1868 };
1869 use std::collections::HashMap;
1870 use uuid::Uuid;
1871
1872 let cid = Uuid::from_u128(0x1111_2222_3333_4444_5555_6666_7777_8888);
1873 let placed = vec![PlacedContainerView {
1874 id: "chest-1".into(),
1875 template_id: "wooden_chest_small".into(),
1876 display_name: "Chest".into(),
1877 x: 0.0,
1878 y: 0.0,
1879 z: 0.0,
1880 locked: false,
1881 accessible: true,
1882 owner_character_id: Some(cid),
1883 contents: vec![],
1884 lock_id: None,
1885 capacity_volume: Some(20.0),
1886 item_instance_id: None,
1887 tile_id: None,
1888 worker_lodging_capacity: None,
1889 blocking: false,
1890 blocking_radius_m: 0.0,
1891 building_id: None,
1892 }];
1893 let blueprints = vec![BlueprintView {
1894 id: "smelt_iron".into(),
1895 label: "Smelt Iron".into(),
1896 output: "iron_ingot".into(),
1897 output_qty: 1,
1898 craft_ticks: 30,
1899 inputs: vec![BlueprintIngredientView {
1900 template_id: "iron_ore".into(),
1901 quantity: 1,
1902 consumed: true,
1903 display_name: "Iron Ore".into(),
1904 }],
1905 station: Some("hand".into()),
1906 category: None,
1907 required_tools: vec![],
1908 skill: None,
1909 failure_chance: 0.0,
1910 worker_train_copper: 0,
1911 output_display_name: "Iron Ingot".into(),
1912 craft_tier: 1,
1913 }];
1914 let nodes = vec![ResourceNodeView {
1915 id: "ore-1".into(),
1916 label: "Iron Ore".into(),
1917 x: 1.0,
1918 y: 1.0,
1919 z: 0.0,
1920 item_template: "iron_ore".into(),
1921 state: ResourceNodeState::Available,
1922 blocking: true,
1923 blocking_radius_m: 0.8,
1924 harvest_off: false,
1925 tile_id: None,
1926 yaw: 0.0,
1927 pitch: 0.0,
1928 roll: 0.0,
1929 draw_scale: 1.0,
1930 sprite_mode: None,
1931 growth_progress: None,
1932 presentation_state: None,
1933 channel_start_tick: None,
1934 channel_end_tick: None,
1935 harvest_drop_templates: vec![],
1936 }];
1937 let ids = route_item_template_candidates(
1938 &placed,
1939 Some(cid),
1940 &HashMap::new(),
1941 &blueprints,
1942 &nodes,
1943 &[],
1944 None,
1945 );
1946 assert!(
1947 ids.contains(&"iron_ingot".to_string()),
1948 "craft output should be selectable before any exists in storage: {ids:?}"
1949 );
1950 assert!(ids.contains(&"iron_ore".to_string()));
1951 }
1952
1953 #[test]
1954 fn sellable_route_candidates_follow_npc_buy_lists() {
1955 use flatland_protocol::NpcView;
1956
1957 fn npc(id: &str, can_trade: bool, buy_templates: &[&str]) -> NpcView {
1958 NpcView {
1959 id: id.into(),
1960 label: id.into(),
1961 role: "merchant".into(),
1962 x: 0.0,
1963 y: 0.0,
1964 building_id: None,
1965 entity_id: None,
1966 life_state: None,
1967 hp_pct: None,
1968 can_trade,
1969 buy_templates: buy_templates.iter().map(|t| (*t).into()).collect(),
1970 tile_id: None,
1971 behavior_state: None,
1972 presentation_state: None,
1973 sprite_mode: None,
1974 paperdoll_ref: None,
1975 draw_scale: 1.0,
1976 yaw: None,
1977 perception_fov_deg: None,
1978 perception_sight_m: None,
1979 perception_hear_m: None,
1980 quest_verbs: Vec::new(),
1981 }
1982 }
1983
1984 let templates = vec![
1985 "carrot".into(),
1986 "carrot_wild".into(),
1987 "carrot_seed".into(),
1988 "oak_log".into(),
1989 ];
1990 let npcs = vec![
1991 npc("maris", true, &["carrot"]),
1992 npc("eli", true, &["carrot_seed"]),
1993 npc("wildlife", false, &["oak_log"]),
1994 ];
1995
1996 assert_eq!(
1997 sellable_route_item_template_candidates(&templates, &npcs, Some("maris")),
1998 vec!["carrot"]
1999 );
2000 assert_eq!(
2001 sellable_route_item_template_candidates(&templates, &npcs, None),
2002 vec!["carrot", "carrot_seed"]
2003 );
2004 }
2005
2006 #[test]
2007 fn route_item_template_includes_harvest_loot_table_drops() {
2008 use flatland_protocol::{ResourceNodeState, ResourceNodeView};
2009 use std::collections::HashMap;
2010 let nodes = vec![ResourceNodeView {
2011 id: "crop-carrot-1".into(),
2012 label: "Wild carrots".into(),
2013 x: 1.0,
2014 y: 1.0,
2015 z: 0.0,
2016 item_template: "carrot_wild".into(),
2017 state: ResourceNodeState::Available,
2018 blocking: false,
2019 blocking_radius_m: 0.8,
2020 harvest_off: false,
2021 tile_id: None,
2022 yaw: 0.0,
2023 pitch: 0.0,
2024 roll: 0.0,
2025 draw_scale: 1.0,
2026 sprite_mode: None,
2027 growth_progress: None,
2028 presentation_state: None,
2029 channel_start_tick: None,
2030 channel_end_tick: None,
2031 harvest_drop_templates: vec!["carrot".into(), "carrot_seed".into()],
2032 }];
2033 let ids =
2034 route_item_template_candidates(&[], None, &HashMap::new(), &[], &nodes, &[], None);
2035 assert!(
2036 ids.contains(&"carrot_seed".to_string()),
2037 "deposit filter should list seeds from harvest loot tables: {ids:?}"
2038 );
2039 assert!(
2040 !ids.contains(&"carrot_wild".to_string()),
2041 "harvest_node templates are not deposit stacks: {ids:?}"
2042 );
2043 }
2044
2045 #[test]
2046 fn route_item_candidates_drop_harvest_node_catalog_entries() {
2047 use flatland_protocol::{ResourceNodeState, ResourceNodeView};
2048 use std::collections::HashMap;
2049 let rocks = "3ee51931-189c-4726-828b-dffb1a3d1fc4";
2050 let stone = "592fe396-cc6e-42d8-8554-4080c3b19036";
2051 let nodes = vec![ResourceNodeView {
2052 id: "rocks-1".into(),
2053 label: "Rocks".into(),
2054 x: 1.0,
2055 y: 1.0,
2056 z: 0.0,
2057 item_template: rocks.into(),
2058 state: ResourceNodeState::Available,
2059 blocking: false,
2060 blocking_radius_m: 0.8,
2061 harvest_off: false,
2062 tile_id: None,
2063 yaw: 0.0,
2064 pitch: 0.0,
2065 roll: 0.0,
2066 draw_scale: 1.0,
2067 sprite_mode: None,
2068 growth_progress: None,
2069 presentation_state: None,
2070 channel_start_tick: None,
2071 channel_end_tick: None,
2072 harvest_drop_templates: vec![stone.into()],
2073 }];
2074 let mut catalog = HashMap::new();
2075 catalog.insert(
2076 rocks.to_string(),
2077 ItemCatalogEntryView {
2078 template_id: rocks.into(),
2079 display_name: "Rocks".into(),
2080 category: "harvest_node".into(),
2081 seed_for: None,
2082 },
2083 );
2084 catalog.insert(
2085 stone.to_string(),
2086 ItemCatalogEntryView {
2087 template_id: stone.into(),
2088 display_name: "Rough Stone".into(),
2089 category: "resource".into(),
2090 seed_for: None,
2091 },
2092 );
2093 let ids = route_item_template_candidates(
2094 &[],
2095 None,
2096 &HashMap::new(),
2097 &[],
2098 &nodes,
2099 &[rocks.into()],
2100 Some(&catalog),
2101 );
2102 assert!(
2103 ids.contains(&stone.to_string()),
2104 "loot drops stay selectable: {ids:?}"
2105 );
2106 assert!(
2107 !ids.contains(&rocks.to_string()),
2108 "Rocks harvest_node must not appear in deposit filter: {ids:?}"
2109 );
2110 }
2111
2112 #[test]
2113 fn build_ordered_job_yaml_includes_stops() {
2114 let mut ed = WorkerRouteEditorState::new(
2115 "worker-worker_laborer-1".into(),
2116 "Laborer".into(),
2117 Some("chest-bed".into()),
2118 );
2119 ed.append_waypoint(10.0, 20.0, 0.0);
2120 ed.append_harvest_node("oak-n1");
2121 ed.append_deposit_at("chest-storage-a");
2122 ed.append_rest_if_needed();
2123 let yaml = ed.build_job_yaml().expect("yaml");
2124 assert!(yaml.contains("kind: ordered"));
2125 assert!(yaml.contains("lodging_container_id: chest-bed"));
2126 assert!(yaml.contains("stop: waypoint"));
2127 assert!(yaml.contains("oak-n1"));
2128 assert!(yaml.contains("deposit_at"));
2129 assert!(yaml.contains("chest-storage-a"));
2130 assert!(yaml.contains("rest_if_needed"));
2131 }
2132
2133 #[test]
2134 fn requires_at_least_one_stop() {
2135 let ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2136 assert!(ed.build_job_yaml().is_err());
2137 }
2138
2139 #[test]
2140 fn reorder_stops() {
2141 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2142 ed.append_harvest_node("oak-a");
2143 ed.append_harvest_node("oak-b");
2144 ed.append_waypoint(5.0, 6.0, 0.0);
2145 ed.select_stop(1);
2147 ed.move_selected_up();
2148 assert!(
2149 matches!(&ed.stops[0], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
2150 );
2151 ed.move_selected_down();
2153 assert!(
2154 matches!(&ed.stops[1], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
2155 );
2156 }
2157
2158 #[test]
2159 fn duplicate_harvest_node_selects_existing_instead() {
2160 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2161 assert!(ed.append_harvest_node("oak-a"));
2162 ed.append_waypoint(1.0, 2.0, 0.0);
2163 assert!(!ed.append_harvest_node("oak-a"));
2164 assert_eq!(ed.stops.len(), 2);
2165 assert_eq!(ed.selected_stop_index, 0);
2166 }
2167
2168 #[test]
2169 fn duplicate_deposit_container_selects_existing_instead() {
2170 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2171 assert!(ed.append_deposit_at("chest-1"));
2172 ed.append_harvest_node("oak-a");
2173 assert!(!ed.append_deposit_at("chest-1"));
2174 assert_eq!(ed.stops.len(), 2);
2175 assert_eq!(ed.selected_stop_index, 0);
2176 }
2177
2178 #[test]
2179 fn duplicate_trade_stop_selects_existing_instead() {
2180 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2181 assert!(ed.append_trade_with("oak_log".into(), Some("ada".into()), true));
2182 assert!(!ed.append_trade_with("oak_log".into(), Some("ada".into()), true));
2183 assert!(ed.append_trade_with("lumber".into(), Some("ada".into()), true));
2185 assert_eq!(ed.stops.len(), 2);
2186 }
2187
2188 #[test]
2189 fn build_idle_job_yaml_parks_worker() {
2190 let ed = WorkerRouteEditorState::new("w1".into(), "L".into(), Some("bed-1".into()));
2191 let yaml = ed.build_idle_job_yaml();
2192 assert!(yaml.contains("mode: idle"));
2193 assert!(yaml.contains("steps: []"));
2194 assert!(!yaml.contains("route:"));
2195 }
2196
2197 #[test]
2198 fn remove_selected_stop_adjusts_index() {
2199 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2200 ed.append_waypoint(1.0, 2.0, 0.0);
2201 ed.append_harvest_node("oak-a");
2202 ed.append_deposit_at("chest-1");
2203 ed.select_stop(2);
2204 ed.remove_selected_stop();
2205 assert_eq!(ed.stops.len(), 2);
2206 assert_eq!(ed.selected_stop_index, 1);
2207 }
2208
2209 #[test]
2210 fn build_job_yaml_includes_trade_with_stop() {
2211 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2212 ed.append_trade_with("oak_log".into(), None, true);
2213 ed.append_trade_with("lumber".into(), Some("ada_broker".into()), false);
2214 let yaml = ed.build_job_yaml().expect("yaml");
2215 assert!(yaml.contains("stop: trade_with, template: oak_log, sell_all: true"));
2216 assert!(yaml.contains("npc_id: ada_broker"));
2217 assert!(yaml.contains("sell_all: false"));
2218 }
2219
2220 #[test]
2221 fn set_selected_trade_npc_updates_stop() {
2222 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2223 ed.append_trade_with("oak_log".into(), None, true);
2224 assert!(ed.set_selected_trade_npc("ada_broker".into()));
2225 assert!(
2226 matches!(&ed.stops[0], WorkerRouteStop::TradeWith { npc_id, .. } if npc_id.as_deref() == Some("ada_broker"))
2227 );
2228 }
2229
2230 #[test]
2231 fn build_job_yaml_deposit_filter_round_trips() {
2232 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), Some("bed-1".into()));
2233 ed.append_deposit_at_filtered("chest-out", vec!["lumber".into()]);
2234 let yaml = ed.build_job_yaml().expect("yaml");
2235 assert!(yaml.contains("stop: deposit_at, container_id: chest-out, filter: [lumber]"));
2236 }
2237
2238 #[test]
2239 fn build_job_yaml_includes_withdraw_and_craft_stops() {
2240 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2241 ed.stops.push(WorkerRouteStop::WithdrawFrom {
2242 container_id: "chest-src".into(),
2243 items: vec![WorkerRouteWithdrawItem {
2244 template: "oak_log".into(),
2245 qty: None,
2246 }],
2247 });
2248 ed.stops.push(WorkerRouteStop::WithdrawFrom {
2249 container_id: "chest-src-2".into(),
2250 items: vec![WorkerRouteWithdrawItem {
2251 template: "iron_ore".into(),
2252 qty: Some(10),
2253 }],
2254 });
2255 ed.stops.push(WorkerRouteStop::CraftAt {
2256 device: "hand".into(),
2257 blueprint: "oak_to_lumber".into(),
2258 qty: None,
2259 });
2260 ed.append_deposit_at("chest-out");
2261 let yaml = ed.build_job_yaml().expect("yaml");
2262 assert!(yaml.contains("stop: withdraw_from"));
2263 assert!(yaml.contains("container_id: chest-src"));
2264 assert!(yaml.contains("template: oak_log, all: true"));
2265 assert!(yaml.contains("template: iron_ore, qty: 10"));
2266 assert!(yaml.contains("stop: craft_at, device: hand, blueprint: oak_to_lumber"));
2267 assert!(yaml.contains("stop: deposit_at"));
2268 }
2269
2270 #[test]
2271 fn withdraw_summary_shows_all_vs_qty() {
2272 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2273 ed.stops.push(WorkerRouteStop::WithdrawFrom {
2274 container_id: "chest-src".into(),
2275 items: vec![WorkerRouteWithdrawItem {
2276 template: "oak_log".into(),
2277 qty: None,
2278 }],
2279 });
2280 assert!(ed.stops[0].summary().contains("withdraw all oak_log"));
2281 }
2282
2283 #[test]
2284 fn withdraw_view_round_trips_all_flag() {
2285 let view = WorkerRouteStopView::WithdrawFrom {
2286 container_id: "chest-1".into(),
2287 items: vec![
2288 flatland_protocol::WorkerWithdrawItemView {
2289 template: "oak_log".into(),
2290 qty: 0,
2291 all: true,
2292 },
2293 flatland_protocol::WorkerWithdrawItemView {
2294 template: "iron_ore".into(),
2295 qty: 5,
2296 all: false,
2297 },
2298 ],
2299 };
2300 let stop = stop_view_to_stop(&view);
2301 let WorkerRouteStop::WithdrawFrom { items, .. } = stop else {
2302 panic!("expected withdraw stop");
2303 };
2304 assert_eq!(items[0].qty, None);
2305 assert_eq!(items[1].qty, Some(5));
2306 }
2307
2308 #[test]
2309 fn legacy_harvest_loop_route_converts_to_ordered_stops() {
2310 let route = WorkerRouteView {
2311 kind: WorkerRouteKindView::HarvestLoop,
2312 lodging_container_id: Some("bed-1".into()),
2313 outbound_waypoints: vec![flatland_protocol::WorkerRouteWaypointView {
2314 x: 1.0,
2315 y: 2.0,
2316 z: 0.0,
2317 }],
2318 harvest_nodes: vec!["oak-1".into()],
2319 carry_return_ratio: 0.9,
2320 stops: Vec::new(),
2321 };
2322 let ed = WorkerRouteEditorState::from_saved_route("w1".into(), "L".into(), &route, None);
2323 assert_eq!(ed.stops.len(), 4);
2325 assert!(
2326 matches!(&ed.stops[2], WorkerRouteStop::DepositAt { container_id, .. } if container_id == "bed-1")
2327 );
2328 assert!(matches!(&ed.stops[3], WorkerRouteStop::RestIfNeeded));
2329 }
2330
2331 #[test]
2334 fn retarget_withdraw_container_updates_editing_stop() {
2335 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2336 ed.stops.push(WorkerRouteStop::WithdrawFrom {
2337 container_id: "chest-old".into(),
2338 items: vec![WorkerRouteWithdrawItem {
2339 template: "iron_ore".into(),
2340 qty: None,
2341 }],
2342 });
2343 ed.selected_stop_index = 0;
2344 ed.editing_index = Some(0);
2345 assert!(ed.retarget_withdraw_container("chest-new".into()));
2346 assert!(matches!(
2347 &ed.stops[0],
2348 WorkerRouteStop::WithdrawFrom { container_id, .. } if container_id == "chest-new"
2349 ));
2350 }
2351
2352 #[test]
2353 fn summary_resolved_uses_friendly_labels() {
2354 let stop = WorkerRouteStop::WithdrawFrom {
2355 container_id: "uuid-iron".into(),
2356 items: vec![WorkerRouteWithdrawItem {
2357 template: "iron_ore".into(),
2358 qty: None,
2359 }],
2360 };
2361 let summary = stop.summary_resolved(
2362 |_| "Iron Ore Container".into(),
2363 |_| "Ada".into(),
2364 |_| "Oak Tree".into(),
2365 |_| "Food Pad".into(),
2366 );
2367 assert_eq!(summary, "withdraw all iron_ore from Iron Ore Container");
2368 assert!(!summary.contains("uuid"));
2369 }
2370
2371 #[test]
2372 fn summary_resolved_uses_plot_labels() {
2373 let plot_id = uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap();
2374 let cultivate = WorkerRouteStop::CultivatePlot { plot_id };
2375 let plant = WorkerRouteStop::PlantPlot {
2376 plot_id,
2377 seed_template: "potato_seed".into(),
2378 };
2379 let harvest = WorkerRouteStop::HarvestPlot { plot_id };
2380 let friendly = |id: &uuid::Uuid| {
2381 assert_eq!(*id, plot_id);
2382 "Madsin — Starter Town East — Food Pad".to_string()
2383 };
2384 let blank = |_: &str| String::new();
2385 assert_eq!(
2386 cultivate.summary_resolved(blank, blank, blank, friendly),
2387 "cultivate Madsin — Starter Town East — Food Pad"
2388 );
2389 assert_eq!(
2390 plant.summary_resolved(blank, blank, blank, friendly),
2391 "plant potato_seed on Madsin — Starter Town East — Food Pad"
2392 );
2393 assert_eq!(
2394 harvest.summary_resolved(blank, blank, blank, friendly),
2395 "harvest Madsin — Starter Town East — Food Pad"
2396 );
2397 let named = cultivate.summary_resolved(blank, blank, blank, friendly);
2398 assert!(
2399 !named.contains("19fe35f"),
2400 "resolved plot summary must not include hex id: {named}"
2401 );
2402 let raw = cultivate.summary();
2403 assert_eq!(raw, "cultivate plot 19fe35f0");
2404 }
2405
2406 #[test]
2407 fn list_filter_row_matches_name_and_distance() {
2408 assert!(list_filter_row_matches(
2409 "oak",
2410 None,
2411 &["Oak Tree", "oak_log"]
2412 ));
2413 assert!(!list_filter_row_matches(
2414 "pine",
2415 None,
2416 &["Oak Tree", "oak_log"]
2417 ));
2418 assert!(list_filter_row_matches(
2419 "oak 50m",
2420 Some(40.0),
2421 &["Oak Tree"]
2422 ));
2423 assert!(!list_filter_row_matches(
2424 "oak 50m",
2425 Some(60.0),
2426 &["Oak Tree"]
2427 ));
2428 assert!(list_filter_row_matches("", Some(999.0), &["anything"]));
2429 }
2430
2431 #[test]
2432 fn node_candidates_sort_from_lodging_anchor_not_player() {
2433 use flatland_protocol::{ResourceNodeState, ResourceNodeView};
2434 fn node(id: &str, label: &str, x: f32) -> ResourceNodeView {
2435 ResourceNodeView {
2436 id: id.into(),
2437 label: label.into(),
2438 x,
2439 y: 0.0,
2440 z: 0.0,
2441 item_template: "oak_log".into(),
2442 state: ResourceNodeState::Available,
2443 blocking: true,
2444 blocking_radius_m: 0.8,
2445 harvest_off: false,
2446 tile_id: None,
2447 yaw: 0.0,
2448 pitch: 0.0,
2449 roll: 0.0,
2450 draw_scale: 1.0,
2451 sprite_mode: None,
2452 presentation_state: None,
2453 growth_progress: None,
2454 channel_start_tick: None,
2455 channel_end_tick: None,
2456 harvest_drop_templates: vec![],
2457 }
2458 }
2459 let nodes = vec![node("far", "Far Oak", 100.0), node("near", "Near Oak", 5.0)];
2460 let sorted = node_candidates(&nodes, 0.0, 0.0);
2461 assert_eq!(sorted[0].id, "near");
2462 assert_eq!(sorted[1].id, "far");
2463 assert!((sorted[0].dist - 5.0).abs() < 0.01);
2464
2465 let stable = node_candidates_stable(&nodes);
2466 assert!(
2467 stable[0].label.starts_with("Far Oak ("),
2468 "got {}",
2469 stable[0].label
2470 );
2471 assert!(
2472 stable[1].label.starts_with("Near Oak ("),
2473 "got {}",
2474 stable[1].label
2475 );
2476 assert!(stable[0].dist.is_nan());
2477 }
2478
2479 #[test]
2480 fn sheet_back_walks_up_hierarchy() {
2481 use RouteEditorSheet as S;
2482 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2483 assert_eq!(ed.sheet, S::Stops);
2484 ed.open_add_menu();
2485 assert_eq!(ed.sheet, S::AddMenu { index: 0 });
2486 ed.open_sheet(S::WithdrawContainers { index: 0 });
2487 ed.open_sheet(S::WithdrawItems {
2488 container_id: "c1".into(),
2489 lines: vec![],
2490 index: 0,
2491 });
2492 ed.sheet_back();
2493 assert_eq!(ed.sheet, S::WithdrawContainers { index: 0 });
2494 ed.sheet_back();
2495 assert_eq!(ed.sheet, S::AddMenu { index: 0 });
2496 ed.sheet_back();
2497 assert_eq!(ed.sheet, S::Stops);
2498 ed.sheet_back();
2500 assert_eq!(ed.sheet, S::Stops);
2501 }
2502
2503 #[test]
2504 fn sheet_back_while_editing_returns_to_stops() {
2505 use RouteEditorSheet as S;
2506 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2507 ed.append_harvest_node("oak-a");
2508 ed.begin_edit_selected();
2509 ed.open_sheet(S::HarvestPicker {
2510 index: 0,
2511 picked: BTreeSet::new(),
2512 nodes: Vec::new(),
2513 });
2514 ed.sheet_back();
2515 assert_eq!(ed.sheet, S::Stops);
2516 assert_eq!(ed.editing_index, None);
2517 }
2518
2519 #[test]
2520 fn sheet_back_while_editing_withdraw_keeps_edit_on_container_picker() {
2521 use RouteEditorSheet as S;
2522 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2523 ed.stops.push(WorkerRouteStop::WithdrawFrom {
2524 container_id: "chest-a".into(),
2525 items: vec![WorkerRouteWithdrawItem {
2526 template: "oak_log".into(),
2527 qty: None,
2528 }],
2529 });
2530 ed.begin_edit_selected();
2531 ed.open_sheet(S::WithdrawItems {
2532 container_id: "chest-a".into(),
2533 lines: vec![],
2534 index: 0,
2535 });
2536 ed.sheet_back();
2537 assert!(matches!(ed.sheet, S::WithdrawContainers { .. }));
2538 assert_eq!(
2539 ed.editing_index,
2540 Some(0),
2541 "still editing after back to picker"
2542 );
2543 ed.sheet_back();
2544 assert_eq!(ed.sheet, S::Stops);
2545 assert_eq!(ed.editing_index, None);
2546 }
2547
2548 #[test]
2549 fn confirm_stop_replaces_withdraw_container_when_editing() {
2550 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2551 ed.stops.push(WorkerRouteStop::WithdrawFrom {
2552 container_id: "chest-old".into(),
2553 items: vec![WorkerRouteWithdrawItem {
2554 template: "oak_log".into(),
2555 qty: None,
2556 }],
2557 });
2558 ed.stops.push(WorkerRouteStop::RestIfNeeded);
2559 ed.select_stop(0);
2560 ed.begin_edit_selected();
2561 assert!(ed.confirm_stop(WorkerRouteStop::WithdrawFrom {
2562 container_id: "chest-new".into(),
2563 items: vec![WorkerRouteWithdrawItem {
2564 template: "oak_log".into(),
2565 qty: None,
2566 }],
2567 }));
2568 assert_eq!(ed.stops.len(), 2);
2569 assert!(matches!(
2570 &ed.stops[0],
2571 WorkerRouteStop::WithdrawFrom { container_id, .. } if container_id == "chest-new"
2572 ));
2573 }
2574
2575 #[test]
2576 fn confirm_stop_replaces_when_editing() {
2577 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2578 ed.append_harvest_node("oak-a");
2579 ed.append_waypoint(1.0, 1.0, 0.0);
2580 ed.select_stop(0);
2581 ed.begin_edit_selected();
2582 assert!(ed.confirm_stop(WorkerRouteStop::HarvestNode {
2583 node_id: "oak-b".into()
2584 }));
2585 assert_eq!(ed.stops.len(), 2, "edit replaces in place, no append");
2586 assert!(
2587 matches!(&ed.stops[0], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
2588 );
2589 assert_eq!(ed.sheet, RouteEditorSheet::Stops);
2590 assert_eq!(ed.editing_index, None);
2591 }
2592
2593 #[test]
2594 fn confirm_stop_dedupes_on_append() {
2595 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2596 ed.append_harvest_node("oak-a");
2597 assert!(!ed.confirm_stop(WorkerRouteStop::HarvestNode {
2598 node_id: "oak-a".into()
2599 }));
2600 assert_eq!(ed.stops.len(), 1);
2601 assert_eq!(ed.selected_stop_index, 0);
2602 }
2603
2604 #[test]
2605 fn withdraw_line_cycle_and_collect() {
2606 let contents = vec![
2607 ItemStack {
2608 template_id: "oak_log".into(),
2609 quantity: 12,
2610 ..Default::default()
2611 },
2612 ItemStack {
2613 template_id: "lumber".into(),
2614 quantity: 4,
2615 ..Default::default()
2616 },
2617 ];
2618 let mut lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &[]);
2619 assert_eq!(lines.len(), 2);
2620 lines[1].cycle();
2622 assert_eq!(lines[1].mode, WithdrawLineMode::All);
2623 lines[0].cycle();
2624 lines[0].cycle();
2625 assert!(matches!(lines[0].mode, WithdrawLineMode::Qty(_)));
2626 lines[0].adjust_qty(5);
2627 let items = WorkerRouteEditorState::withdraw_items_from_lines(&lines);
2628 assert_eq!(items.len(), 2);
2629 assert_eq!(items[0].template, "lumber");
2630 assert_eq!(items[0].qty, Some(4));
2632 assert_eq!(items[1].qty, None);
2633 }
2634
2635 #[test]
2636 fn withdraw_drafts_prefill_existing_and_keep_missing() {
2637 let contents = vec![ItemStack {
2638 template_id: "oak_log".into(),
2639 quantity: 3,
2640 ..Default::default()
2641 }];
2642 let existing = vec![
2643 WorkerRouteWithdrawItem {
2644 template: "oak_log".into(),
2645 qty: None,
2646 },
2647 WorkerRouteWithdrawItem {
2648 template: "iron_ore".into(),
2649 qty: Some(5),
2650 },
2651 ];
2652 let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
2653 assert_eq!(lines.len(), 2);
2654 let ore = lines
2655 .iter()
2656 .find(|l| l.template == "iron_ore")
2657 .expect("ore line");
2658 assert_eq!(ore.available, 0, "missing template kept with 0 available");
2659 assert_eq!(ore.mode, WithdrawLineMode::Qty(5));
2660 let oak = lines
2661 .iter()
2662 .find(|l| l.template == "oak_log")
2663 .expect("oak line");
2664 assert_eq!(oak.mode, WithdrawLineMode::All);
2665 }
2666}