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