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