1use flatland_protocol::{
16 ItemStack, NpcView, PlacedContainerView, ResourceNodeView, WorkerRouteKindView,
17 WorkerRouteStopView, WorkerRouteView,
18};
19
20#[derive(Debug, Clone, PartialEq)]
22pub struct WorkerRouteWaypoint {
23 pub x: f32,
24 pub y: f32,
25 pub z: f32,
26}
27
28#[derive(Debug, Clone, PartialEq)]
30pub enum WorkerRouteStop {
31 Waypoint { x: f32, y: f32, z: f32 },
32 HarvestNode { node_id: String },
33 DepositAt {
34 container_id: String,
35 filter: Option<Vec<String>>,
37 },
38 TradeWith {
41 npc_id: Option<String>,
42 template: String,
43 sell_all: bool,
44 },
45 WithdrawFrom {
47 container_id: String,
48 items: Vec<WorkerRouteWithdrawItem>,
49 },
50 CraftAt {
52 device: String,
53 blueprint: String,
54 qty: Option<u32>,
55 },
56 RestIfNeeded,
57 Wait { wait_ticks: u64 },
58}
59
60#[derive(Debug, Clone, PartialEq)]
62pub struct WorkerRouteWithdrawItem {
63 pub template: String,
64 pub qty: Option<u32>,
67}
68
69impl WorkerRouteStop {
70 pub fn summary(&self) -> String {
71 self.summary_resolved(|id| short_id(id), |id| id.to_string(), |id| id.to_string())
72 }
73
74 pub fn summary_resolved(
76 &self,
77 container_label: impl Fn(&str) -> String,
78 npc_label: impl Fn(&str) -> String,
79 node_label: impl Fn(&str) -> String,
80 ) -> String {
81 match self {
82 Self::Waypoint { x, y, .. } => format!("waypoint ({x:.0}, {y:.0})"),
83 Self::HarvestNode { node_id } => format!("harvest {}", node_label(node_id)),
84 Self::DepositAt { container_id, filter } => {
85 let f = filter
86 .as_ref()
87 .map(|f| format!(" only {}", f.join(",")))
88 .unwrap_or_default();
89 format!("deposit at {}{f}", container_label(container_id))
90 }
91 Self::TradeWith { npc_id, template, .. } => {
92 let who = npc_id
93 .as_deref()
94 .map(|id| npc_label(id))
95 .unwrap_or_else(|| "nearest buyer".into());
96 format!("sell {template} to {who}")
97 }
98 Self::WithdrawFrom { container_id, items } => {
99 let what = items
100 .iter()
101 .map(|i| match i.qty {
102 None => format!("all {}", i.template),
103 Some(q) => format!("{q} {}", i.template),
104 })
105 .collect::<Vec<_>>()
106 .join(" + ");
107 format!("withdraw {what} from {}", container_label(container_id))
108 }
109 Self::CraftAt { blueprint, .. } => format!("craft {blueprint}"),
110 Self::RestIfNeeded => "rest if needed".into(),
111 Self::Wait { wait_ticks } => format!("wait {wait_ticks}t"),
112 }
113 }
114
115 pub fn kind_label(&self) -> &'static str {
117 match self {
118 Self::Waypoint { .. } => "waypoint",
119 Self::HarvestNode { .. } => "harvest",
120 Self::DepositAt { .. } => "deposit",
121 Self::TradeWith { .. } => "sell",
122 Self::WithdrawFrom { .. } => "withdraw",
123 Self::CraftAt { .. } => "craft",
124 Self::RestIfNeeded => "rest",
125 Self::Wait { .. } => "wait",
126 }
127 }
128}
129
130fn short_id(id: &str) -> String {
131 id.rsplit('-')
132 .next()
133 .filter(|s| !s.is_empty())
134 .unwrap_or(id)
135 .to_string()
136}
137
138#[derive(Debug, Clone, Copy, PartialEq)]
142pub enum WithdrawLineMode {
143 Off,
144 All,
145 Qty(u32),
146}
147
148#[derive(Debug, Clone, PartialEq)]
150pub struct WithdrawLineDraft {
151 pub template: String,
152 pub available: u32,
154 pub mode: WithdrawLineMode,
155}
156
157impl WithdrawLineDraft {
158 pub fn cycle(&mut self) {
160 self.mode = match self.mode {
161 WithdrawLineMode::Off => WithdrawLineMode::All,
162 WithdrawLineMode::All => WithdrawLineMode::Qty(self.available.clamp(1, 10)),
163 WithdrawLineMode::Qty(_) => WithdrawLineMode::Off,
164 };
165 }
166
167 pub fn adjust_qty(&mut self, delta: i32) {
169 let cur = match self.mode {
170 WithdrawLineMode::Off => self.available.clamp(1, 10),
171 WithdrawLineMode::All => self.available.clamp(1, 10),
172 WithdrawLineMode::Qty(q) => q,
173 };
174 let next = (cur as i32 + delta).clamp(1, self.available.max(1) as i32) as u32;
175 self.mode = WithdrawLineMode::Qty(next);
176 }
177}
178
179#[derive(Debug, Clone, PartialEq)]
181pub enum RouteEditorSheet {
182 Stops,
184 AddMenu { index: usize },
186 WaypointMenu { index: usize },
188 WaypointMapPick,
190 HarvestPicker { index: usize },
192 WithdrawContainers { index: usize },
194 WithdrawItems {
196 container_id: String,
197 lines: Vec<WithdrawLineDraft>,
198 index: usize,
199 },
200 DepositContainers { index: usize },
202 DepositFilter {
204 container_id: String,
205 rows: Vec<(String, bool)>,
207 index: usize,
208 },
209 SellNpcs { index: usize },
211 SellItem {
213 npc_id: Option<String>,
214 templates: Vec<String>,
215 index: usize,
216 sell_all: bool,
217 },
218 CraftBlueprint { index: usize },
220 WaitEntry { ticks: u64 },
222 BedPicker { index: usize },
224}
225
226#[derive(Debug, Clone, Copy, PartialEq)]
228pub enum RouteEditorClick {
229 SelectStop(usize),
231 OpenBedPicker,
233 SheetRow(usize),
235 TogglePanel,
237}
238
239pub const ADD_MENU: &[&str] = &[
241 "Waypoint",
242 "Harvest node",
243 "Withdraw from storage",
244 "Deposit to storage",
245 "Sell to merchant",
246 "Craft (at hand)",
247 "Rest if needed",
248 "Wait",
249];
250
251pub const WAYPOINT_MENU: &[&str] = &["At player position", "Pick on map (click)"];
253
254#[derive(Debug, Clone, PartialEq)]
258pub struct ContainerCandidate {
259 pub id: String,
260 pub name: String,
261 pub is_lodging: bool,
262 pub summary: String,
264 pub dist: f32,
265}
266
267pub fn summarize_contents(contents: &[ItemStack]) -> String {
269 let mut totals: Vec<(String, u32)> = Vec::new();
270 for s in contents {
271 if s.template_id.is_empty() {
272 continue;
273 }
274 match totals.iter_mut().find(|(t, _)| t == &s.template_id) {
275 Some((_, q)) => *q += s.quantity,
276 None => totals.push((s.template_id.clone(), s.quantity)),
277 }
278 }
279 if totals.is_empty() {
280 return "(empty)".into();
281 }
282 totals.sort();
283 totals
284 .iter()
285 .map(|(t, q)| format!("{t} ×{q}"))
286 .collect::<Vec<_>>()
287 .join(" · ")
288}
289
290pub fn owned_container_candidates(
293 placed: &[PlacedContainerView],
294 character_id: Option<uuid::Uuid>,
295 px: f32,
296 py: f32,
297) -> Vec<ContainerCandidate> {
298 let Some(cid) = character_id else {
299 return Vec::new();
300 };
301 let mut out: Vec<ContainerCandidate> = placed
302 .iter()
303 .filter(|c| c.owner_character_id == Some(cid))
304 .filter(|c| {
305 c.capacity_volume.unwrap_or(0.0) > 0.0 || c.worker_lodging_capacity.unwrap_or(0) > 0
306 })
307 .map(|c| ContainerCandidate {
308 id: c.id.clone(),
309 name: c.display_name.clone(),
310 is_lodging: c.worker_lodging_capacity.unwrap_or(0) > 0,
311 summary: summarize_contents(&c.contents),
312 dist: dist2d(px, py, c.x, c.y),
313 })
314 .collect();
315 out.sort_by(|a, b| {
316 a.dist
317 .partial_cmp(&b.dist)
318 .unwrap_or(std::cmp::Ordering::Equal)
319 .then_with(|| a.name.cmp(&b.name))
320 .then_with(|| a.id.cmp(&b.id))
321 });
322 out
323}
324
325#[derive(Debug, Clone, PartialEq)]
327pub struct NodeCandidate {
328 pub id: String,
329 pub label: String,
330 pub template: String,
331 pub dist: f32,
332}
333
334pub fn node_candidates(nodes: &[ResourceNodeView], px: f32, py: f32) -> Vec<NodeCandidate> {
336 let mut out: Vec<NodeCandidate> = nodes
337 .iter()
338 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
339 .map(|n| NodeCandidate {
340 id: n.id.clone(),
341 label: n.label.clone(),
342 template: n.item_template.clone(),
343 dist: dist2d(px, py, n.x, n.y),
344 })
345 .collect();
346 out.sort_by(|a, b| {
347 a.dist
348 .partial_cmp(&b.dist)
349 .unwrap_or(std::cmp::Ordering::Equal)
350 .then_with(|| a.id.cmp(&b.id))
351 });
352 out
353}
354
355#[derive(Debug, Clone, PartialEq)]
357pub struct TradeNpcCandidate {
358 pub id: String,
359 pub label: String,
360 pub dist: f32,
361}
362
363pub fn trade_npc_candidates(npcs: &[NpcView], px: f32, py: f32) -> Vec<TradeNpcCandidate> {
365 let mut out: Vec<TradeNpcCandidate> = npcs
366 .iter()
367 .filter(|n| n.can_trade)
368 .map(|n| TradeNpcCandidate {
369 id: n.id.clone(),
370 label: n.label.clone(),
371 dist: dist2d(px, py, n.x, n.y),
372 })
373 .collect();
374 out.sort_by(|a, b| {
375 a.dist
376 .partial_cmp(&b.dist)
377 .unwrap_or(std::cmp::Ordering::Equal)
378 .then_with(|| a.id.cmp(&b.id))
379 });
380 out
381}
382
383#[derive(Debug, Clone)]
388pub struct WorkerRouteEditorState {
389 pub worker_instance_id: String,
390 pub worker_label: String,
391 pub lodging_container_id: Option<String>,
392 pub stops: Vec<WorkerRouteStop>,
394 pub selected_stop_index: usize,
396 pub carry_return_ratio: f32,
398 pub sheet: RouteEditorSheet,
400 pub editing_index: Option<usize>,
403 pub panel_collapsed: bool,
405}
406
407impl WorkerRouteEditorState {
408 pub fn new(
409 worker_instance_id: String,
410 worker_label: String,
411 lodging_container_id: Option<String>,
412 ) -> Self {
413 Self {
414 worker_instance_id,
415 worker_label,
416 lodging_container_id,
417 stops: Vec::new(),
418 selected_stop_index: 0,
419 carry_return_ratio: 0.90,
420 sheet: RouteEditorSheet::Stops,
421 editing_index: None,
422 panel_collapsed: false,
423 }
424 }
425
426 pub fn toggle_panel_collapsed(&mut self) {
427 self.panel_collapsed = !self.panel_collapsed;
428 }
429
430 pub fn from_saved_route(
431 worker_instance_id: String,
432 worker_label: String,
433 route: &WorkerRouteView,
434 lodging_fallback: Option<String>,
435 ) -> Self {
436 let lodging = route
437 .lodging_container_id
438 .clone()
439 .or(lodging_fallback);
440
441 match route.kind {
442 WorkerRouteKindView::Ordered => Self {
443 worker_instance_id,
444 worker_label,
445 lodging_container_id: lodging,
446 stops: route
447 .stops
448 .iter()
449 .map(stop_view_to_stop)
450 .collect(),
451 selected_stop_index: 0,
452 carry_return_ratio: route.carry_return_ratio,
453 sheet: RouteEditorSheet::Stops,
454 editing_index: None,
455 panel_collapsed: false,
456 },
457 WorkerRouteKindView::HarvestLoop => {
458 let mut stops = Vec::new();
463 for wp in &route.outbound_waypoints {
464 stops.push(WorkerRouteStop::Waypoint {
465 x: wp.x,
466 y: wp.y,
467 z: wp.z,
468 });
469 }
470 for node in &route.harvest_nodes {
471 stops.push(WorkerRouteStop::HarvestNode {
472 node_id: node.clone(),
473 });
474 }
475 if let Some(lodging) = &lodging {
476 stops.push(WorkerRouteStop::DepositAt {
477 container_id: lodging.clone(),
478 filter: None,
479 });
480 stops.push(WorkerRouteStop::RestIfNeeded);
481 }
482 Self {
483 worker_instance_id,
484 worker_label,
485 lodging_container_id: lodging,
486 stops,
487 selected_stop_index: 0,
488 carry_return_ratio: route.carry_return_ratio,
489 sheet: RouteEditorSheet::Stops,
490 editing_index: None,
491 panel_collapsed: false,
492 }
493 }
494 }
495 }
496
497 pub fn stop_count(&self) -> usize {
500 self.stops.len()
501 }
502
503 pub fn select_stop(&mut self, index: usize) {
504 if self.stops.is_empty() {
505 self.selected_stop_index = 0;
506 return;
507 }
508 self.selected_stop_index = index.min(self.stops.len() - 1);
509 }
510
511 pub fn move_selected_up(&mut self) {
512 if self.selected_stop_index == 0 {
513 return;
514 }
515 self.stops
516 .swap(self.selected_stop_index, self.selected_stop_index - 1);
517 self.selected_stop_index -= 1;
518 }
519
520 pub fn move_selected_down(&mut self) {
521 if self.selected_stop_index + 1 >= self.stops.len() {
522 return;
523 }
524 self.stops
525 .swap(self.selected_stop_index, self.selected_stop_index + 1);
526 self.selected_stop_index += 1;
527 }
528
529 pub fn remove_selected_stop(&mut self) {
530 if self.stops.is_empty() {
531 return;
532 }
533 let idx = self.selected_stop_index.min(self.stops.len() - 1);
534 self.stops.remove(idx);
535 self.editing_index = None;
536 if self.selected_stop_index >= self.stops.len() {
537 self.selected_stop_index = self.stops.len().saturating_sub(1);
538 }
539 }
540
541 fn find_stop(&self, pred: impl Fn(&WorkerRouteStop) -> bool) -> Option<usize> {
543 self.stops.iter().position(pred)
544 }
545
546 pub fn harvest_node_index(&self, node_id: &str) -> Option<usize> {
547 self.find_stop(
548 |s| matches!(s, WorkerRouteStop::HarvestNode { node_id: n } if n == node_id),
549 )
550 }
551
552 pub fn deposit_container_index(&self, container_id: &str) -> Option<usize> {
553 self.find_stop(
554 |s| matches!(s, WorkerRouteStop::DepositAt { container_id: c, .. } if c == container_id),
555 )
556 }
557
558 pub fn trade_stop_index(&self, npc_id: Option<&str>, template: &str) -> Option<usize> {
559 self.find_stop(|s| {
560 matches!(s, WorkerRouteStop::TradeWith { npc_id: n, template: t, .. }
561 if n.as_deref() == npc_id && t == template)
562 })
563 }
564
565 pub fn insert_stop(&mut self, stop: WorkerRouteStop) -> (bool, usize) {
569 let existing = match &stop {
570 WorkerRouteStop::HarvestNode { node_id } => self.harvest_node_index(node_id),
571 WorkerRouteStop::DepositAt { container_id, .. } => {
572 self.deposit_container_index(container_id)
573 }
574 WorkerRouteStop::TradeWith { npc_id, template, .. } => {
575 self.trade_stop_index(npc_id.as_deref(), template)
576 }
577 _ => None,
578 };
579 if let Some(idx) = existing {
580 self.selected_stop_index = idx;
581 return (false, idx);
582 }
583 self.stops.push(stop);
584 self.selected_stop_index = self.stops.len() - 1;
585 (true, self.stops.len() - 1)
586 }
587
588 pub fn append_waypoint(&mut self, x: f32, y: f32, z: f32) {
589 self.insert_stop(WorkerRouteStop::Waypoint { x, y, z });
590 }
591
592 pub fn append_harvest_node(&mut self, node_id: &str) -> bool {
593 self.insert_stop(WorkerRouteStop::HarvestNode {
594 node_id: node_id.to_string(),
595 })
596 .0
597 }
598
599 pub fn append_deposit_at(&mut self, container_id: &str) -> bool {
600 self.insert_stop(WorkerRouteStop::DepositAt {
601 container_id: container_id.to_string(),
602 filter: None,
603 })
604 .0
605 }
606
607 pub fn append_deposit_at_filtered(&mut self, container_id: &str, filter_templates: Vec<String>) {
610 self.stops.push(WorkerRouteStop::DepositAt {
611 container_id: container_id.to_string(),
612 filter: Some(filter_templates),
613 });
614 self.selected_stop_index = self.stops.len() - 1;
615 }
616
617 pub fn append_rest_if_needed(&mut self) {
618 self.insert_stop(WorkerRouteStop::RestIfNeeded);
619 }
620
621 pub fn append_wait(&mut self, wait_ticks: u64) {
622 self.insert_stop(WorkerRouteStop::Wait { wait_ticks });
623 }
624
625 pub fn append_trade_with(&mut self, template: String, npc_id: Option<String>, sell_all: bool) -> bool {
629 self.insert_stop(WorkerRouteStop::TradeWith {
630 npc_id,
631 template,
632 sell_all,
633 })
634 .0
635 }
636
637 pub fn set_selected_trade_npc(&mut self, npc_id: String) -> bool {
642 let Some(stop) = self.stops.get_mut(self.selected_stop_index) else {
643 return false;
644 };
645 let WorkerRouteStop::TradeWith { npc_id: slot, template, .. } = stop else {
646 return false;
647 };
648 *slot = Some(npc_id.clone());
649 let template = template.clone();
650 let selected = self.selected_stop_index;
651 if let Some(other) = self
652 .trade_stop_index(Some(npc_id.as_str()), template.as_str())
653 .filter(|&i| i != selected)
654 {
655 self.stops.remove(selected);
656 self.selected_stop_index = if other > selected { other - 1 } else { other };
657 }
658 true
659 }
660
661 pub fn set_selected_withdraw_container(&mut self, container_id: String) -> bool {
665 let Some(stop) = self.stops.get_mut(self.selected_stop_index) else {
666 return false;
667 };
668 if let WorkerRouteStop::WithdrawFrom { container_id: slot, .. } = stop {
669 *slot = container_id;
670 return true;
671 }
672 false
673 }
674
675 pub fn retarget_withdraw_container(&mut self, container_id: String) -> bool {
678 let idx = self.editing_index.unwrap_or(self.selected_stop_index);
679 let Some(stop) = self.stops.get_mut(idx) else {
680 return false;
681 };
682 if let WorkerRouteStop::WithdrawFrom { container_id: slot, .. } = stop {
683 *slot = container_id;
684 return true;
685 }
686 false
687 }
688
689 pub fn retarget_deposit_container(&mut self, container_id: String) -> bool {
692 let idx = self.editing_index.unwrap_or(self.selected_stop_index);
693 let Some(stop) = self.stops.get_mut(idx) else {
694 return false;
695 };
696 if let WorkerRouteStop::DepositAt { container_id: slot, .. } = stop {
697 *slot = container_id;
698 return true;
699 }
700 false
701 }
702
703 pub fn open_add_menu(&mut self) {
706 self.editing_index = None;
707 self.sheet = RouteEditorSheet::AddMenu { index: 0 };
708 }
709
710 pub fn open_sheet(&mut self, sheet: RouteEditorSheet) {
711 self.sheet = sheet;
712 }
713
714 pub fn begin_edit_selected(&mut self) {
717 if self.selected_stop_index < self.stops.len() {
718 self.editing_index = Some(self.selected_stop_index);
719 }
720 }
721
722 pub fn sheet_back(&mut self) {
726 use RouteEditorSheet as S;
727 let editing = self.editing_index.is_some();
728 let next = match &self.sheet {
729 S::Stops => return,
730 S::AddMenu { .. } | S::BedPicker { .. } => S::Stops,
731 S::WaypointMapPick => {
732 if editing {
733 S::Stops
734 } else {
735 S::WaypointMenu { index: 0 }
736 }
737 }
738 S::WithdrawItems { .. } => S::WithdrawContainers { index: 0 },
741 S::DepositFilter { .. } => S::DepositContainers { index: 0 },
742 S::SellItem { .. } => S::SellNpcs { index: 0 },
743 S::WithdrawContainers { .. }
744 | S::DepositContainers { .. }
745 | S::SellNpcs { .. }
746 if editing =>
747 {
748 S::Stops
749 }
750 _ => {
752 if editing {
753 S::Stops
754 } else {
755 S::AddMenu { index: 0 }
756 }
757 }
758 };
759 if matches!(next, S::Stops) {
760 self.editing_index = None;
761 }
762 self.sheet = next;
763 }
764
765 pub fn confirm_stop(&mut self, stop: WorkerRouteStop) -> bool {
769 let result = if let Some(idx) = self.editing_index.take() {
770 if idx < self.stops.len() {
771 self.stops[idx] = stop;
772 self.selected_stop_index = idx;
773 }
774 true
775 } else {
776 self.insert_stop(stop).0
777 };
778 self.sheet = RouteEditorSheet::Stops;
779 result
780 }
781
782 pub fn withdraw_line_drafts(
786 contents: &[ItemStack],
787 existing: &[WorkerRouteWithdrawItem],
788 ) -> Vec<WithdrawLineDraft> {
789 let mut lines: Vec<WithdrawLineDraft> = Vec::new();
790 for s in contents {
791 if s.template_id.is_empty() {
792 continue;
793 }
794 match lines.iter_mut().find(|l| l.template == s.template_id) {
795 Some(l) => l.available += s.quantity,
796 None => lines.push(WithdrawLineDraft {
797 template: s.template_id.clone(),
798 available: s.quantity,
799 mode: WithdrawLineMode::Off,
800 }),
801 }
802 }
803 for item in existing {
804 let mode = match item.qty {
805 None => WithdrawLineMode::All,
806 Some(q) => WithdrawLineMode::Qty(q),
807 };
808 match lines.iter_mut().find(|l| l.template == item.template) {
809 Some(l) => l.mode = mode,
810 None => lines.push(WithdrawLineDraft {
811 template: item.template.clone(),
812 available: 0,
813 mode,
814 }),
815 }
816 }
817 lines.sort_by(|a, b| a.template.cmp(&b.template));
818 lines
819 }
820
821 pub fn withdraw_items_from_lines(lines: &[WithdrawLineDraft]) -> Vec<WorkerRouteWithdrawItem> {
823 lines
824 .iter()
825 .filter_map(|l| match l.mode {
826 WithdrawLineMode::Off => None,
827 WithdrawLineMode::All => Some(WorkerRouteWithdrawItem {
828 template: l.template.clone(),
829 qty: None,
830 }),
831 WithdrawLineMode::Qty(q) => Some(WorkerRouteWithdrawItem {
832 template: l.template.clone(),
833 qty: Some(q),
834 }),
835 })
836 .collect()
837 }
838
839 fn job_id(&self) -> String {
842 format!(
843 "route_{}",
844 self.worker_instance_id
845 .chars()
846 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
847 .collect::<String>()
848 )
849 }
850
851 pub fn build_idle_job_yaml(&self) -> String {
855 let job_id = self.job_id();
858 [format!("job_id: {job_id}"), "mode: idle".into(), "steps: []".into()].join("\n")
859 }
860
861 pub fn build_job_yaml(&self) -> Result<String, String> {
862 if self.stops.is_empty() {
863 return Err("add at least one stop (waypoint, harvest node, or deposit)".into());
864 }
865 let job_id = self.job_id();
866 let mut lines = vec![
867 format!("job_id: {job_id}"),
868 "mode: job_loop".into(),
869 "route:".into(),
870 " kind: ordered".into(),
871 ];
872 if let Some(lodging) = &self.lodging_container_id {
873 lines.push(format!(" lodging_container_id: {lodging}"));
874 }
875 lines.push(format!(
876 " carry_return_ratio: {:.2}",
877 self.carry_return_ratio
878 ));
879 lines.push(" stops:".into());
880 for stop in &self.stops {
881 match stop {
882 WorkerRouteStop::Waypoint { x, y, z } => {
883 lines.push(format!(
884 " - {{ stop: waypoint, x: {:.1}, y: {:.1}, z: {:.1} }}",
885 x, y, z
886 ));
887 }
888 WorkerRouteStop::HarvestNode { node_id } => {
889 lines.push(format!(" - {{ stop: harvest_node, node_id: {node_id} }}"));
890 }
891 WorkerRouteStop::DepositAt { container_id, filter } => {
892 let f = filter
893 .as_ref()
894 .filter(|f| !f.is_empty())
895 .map(|f| format!(", filter: [{}]", f.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")))
896 .unwrap_or_default();
897 lines.push(format!(
898 " - {{ stop: deposit_at, container_id: {container_id}{f} }}"
899 ));
900 }
901 WorkerRouteStop::RestIfNeeded => {
902 lines.push(" - { stop: rest_if_needed }".into());
903 }
904 WorkerRouteStop::Wait { wait_ticks } => {
905 lines.push(format!(" - {{ stop: wait, wait_ticks: {wait_ticks} }}"));
906 }
907 WorkerRouteStop::TradeWith {
908 npc_id,
909 template,
910 sell_all,
911 } => {
912 let who = npc_id
913 .as_deref()
914 .map(|n| format!(", npc_id: {n}"))
915 .unwrap_or_default();
916 lines.push(format!(
917 " - {{ stop: trade_with, template: {template}{who}, sell_all: {sell_all} }}"
918 ));
919 }
920 WorkerRouteStop::WithdrawFrom { container_id, items } => {
921 let mut block = format!(
922 " - stop: withdraw_from\n container_id: {container_id}\n items:"
923 );
924 for it in items {
925 let line = match it.qty {
926 None => format!("\n - {{ template: {}, all: true }}", it.template),
927 Some(q) => format!("\n - {{ template: {}, qty: {} }}", it.template, q),
928 };
929 block.push_str(&line);
930 }
931 lines.push(block);
932 }
933 WorkerRouteStop::CraftAt {
934 device,
935 blueprint,
936 qty,
937 } => {
938 let qty_str = qty
939 .map(|q| format!(", qty: {q}"))
940 .unwrap_or_default();
941 lines.push(format!(
942 " - {{ stop: craft_at, device: {device}, blueprint: {blueprint}{qty_str} }}"
943 ));
944 }
945 }
946 }
947 lines.push("steps: []".into());
948 Ok(lines.join("\n"))
949 }
950
951 pub fn to_route_view(&self) -> flatland_protocol::WorkerRouteView {
953 use flatland_protocol::{
954 WorkerRouteKindView, WorkerRouteStopView, WorkerRouteView, WorkerWithdrawItemView,
955 };
956 WorkerRouteView {
957 kind: WorkerRouteKindView::Ordered,
958 lodging_container_id: self.lodging_container_id.clone(),
959 outbound_waypoints: Vec::new(),
960 harvest_nodes: Vec::new(),
961 carry_return_ratio: self.carry_return_ratio,
962 stops: self
963 .stops
964 .iter()
965 .map(|stop| match stop {
966 WorkerRouteStop::Waypoint { x, y, z } => WorkerRouteStopView::Waypoint {
967 x: *x,
968 y: *y,
969 z: *z,
970 },
971 WorkerRouteStop::HarvestNode { node_id } => WorkerRouteStopView::HarvestNode {
972 node_id: node_id.clone(),
973 },
974 WorkerRouteStop::DepositAt { container_id, filter } => {
975 WorkerRouteStopView::DepositAt {
976 container_id: container_id.clone(),
977 filter: filter.clone(),
978 }
979 }
980 WorkerRouteStop::TradeWith {
981 npc_id,
982 template,
983 sell_all,
984 } => WorkerRouteStopView::TradeWith {
985 npc_id: npc_id.clone(),
986 template: template.clone(),
987 sell_all: *sell_all,
988 },
989 WorkerRouteStop::WithdrawFrom { container_id, items } => {
990 WorkerRouteStopView::WithdrawFrom {
991 container_id: container_id.clone(),
992 items: items
993 .iter()
994 .map(|i| WorkerWithdrawItemView {
995 template: i.template.clone(),
996 qty: i.qty.unwrap_or(0),
997 all: i.qty.is_none(),
998 })
999 .collect(),
1000 }
1001 }
1002 WorkerRouteStop::CraftAt {
1003 device,
1004 blueprint,
1005 qty,
1006 } => WorkerRouteStopView::CraftAt {
1007 device: device.clone(),
1008 blueprint: blueprint.clone(),
1009 qty: *qty,
1010 },
1011 WorkerRouteStop::RestIfNeeded => WorkerRouteStopView::RestIfNeeded,
1012 WorkerRouteStop::Wait { wait_ticks } => WorkerRouteStopView::Wait {
1013 wait_ticks: *wait_ticks,
1014 },
1015 })
1016 .collect(),
1017 }
1018 }
1019}
1020
1021fn stop_view_to_stop(view: &WorkerRouteStopView) -> WorkerRouteStop {
1022 match view {
1023 WorkerRouteStopView::Waypoint { x, y, z } => WorkerRouteStop::Waypoint {
1024 x: *x,
1025 y: *y,
1026 z: *z,
1027 },
1028 WorkerRouteStopView::HarvestNode { node_id } => WorkerRouteStop::HarvestNode {
1029 node_id: node_id.clone(),
1030 },
1031 WorkerRouteStopView::DepositAt { container_id, filter } => WorkerRouteStop::DepositAt {
1032 container_id: container_id.clone(),
1033 filter: filter.clone(),
1034 },
1035 WorkerRouteStopView::TradeWith {
1036 npc_id,
1037 template,
1038 sell_all,
1039 } => WorkerRouteStop::TradeWith {
1040 npc_id: npc_id.clone(),
1041 template: template.clone(),
1042 sell_all: *sell_all,
1043 },
1044 WorkerRouteStopView::WithdrawFrom { container_id, items } => WorkerRouteStop::WithdrawFrom {
1045 container_id: container_id.clone(),
1046 items: items
1047 .iter()
1048 .map(|i| WorkerRouteWithdrawItem {
1049 template: i.template.clone(),
1050 qty: if i.all { None } else { Some(i.qty) },
1051 })
1052 .collect(),
1053 },
1054 WorkerRouteStopView::CraftAt {
1055 device,
1056 blueprint,
1057 qty,
1058 } => WorkerRouteStop::CraftAt {
1059 device: device.clone(),
1060 blueprint: blueprint.clone(),
1061 qty: *qty,
1062 },
1063 WorkerRouteStopView::RestIfNeeded => WorkerRouteStop::RestIfNeeded,
1064 WorkerRouteStopView::Wait { wait_ticks } => WorkerRouteStop::Wait {
1065 wait_ticks: *wait_ticks,
1066 },
1067 }
1068}
1069
1070const HARVEST_NODE_PICK_M: f32 = 4.0;
1073const LODGING_PICK_M: f32 = 5.0;
1074const STORAGE_PICK_M: f32 = 5.0;
1075
1076fn dist2d(x0: f32, y0: f32, x1: f32, y1: f32) -> f32 {
1077 let dx = x0 - x1;
1078 let dy = y0 - y1;
1079 (dx * dx + dy * dy).sqrt()
1080}
1081
1082pub fn pick_resource_node_at<'a>(
1084 nodes: &'a [ResourceNodeView],
1085 x: f32,
1086 y: f32,
1087) -> Option<&'a ResourceNodeView> {
1088 nodes
1089 .iter()
1090 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
1091 .filter_map(|n| {
1092 let d = dist2d(x, y, n.x, n.y);
1093 if d <= HARVEST_NODE_PICK_M {
1094 Some((d, n))
1095 } else {
1096 None
1097 }
1098 })
1099 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1100 .map(|(_, n)| n)
1101}
1102
1103pub fn owned_lodging_container_ids(
1105 placed: &[PlacedContainerView],
1106 character_id: Option<uuid::Uuid>,
1107) -> Vec<(String, String)> {
1108 let Some(cid) = character_id else {
1109 return Vec::new();
1110 };
1111 let mut out: Vec<(String, String)> = placed
1112 .iter()
1113 .filter(|c| c.worker_lodging_capacity.unwrap_or(0) > 0)
1114 .filter(|c| c.owner_character_id == Some(cid))
1115 .map(|c| (c.id.clone(), c.display_name.clone()))
1116 .collect();
1117 out.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
1118 out
1119}
1120
1121pub fn pick_lodging_container_at(
1122 placed: &[PlacedContainerView],
1123 character_id: Option<uuid::Uuid>,
1124 x: f32,
1125 y: f32,
1126) -> Option<String> {
1127 let cid = character_id?;
1128 placed
1129 .iter()
1130 .filter(|c| c.worker_lodging_capacity.unwrap_or(0) > 0)
1131 .filter(|c| c.owner_character_id == Some(cid))
1132 .filter_map(|c| {
1133 let d = dist2d(x, y, c.x, c.y);
1134 if d <= LODGING_PICK_M {
1135 Some((d, c.id.clone()))
1136 } else {
1137 None
1138 }
1139 })
1140 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1141 .map(|(_, id)| id)
1142}
1143
1144pub fn pick_trade_npc_at(
1146 npcs: &[NpcView],
1147 x: f32,
1148 y: f32,
1149) -> Option<(String, String)> {
1150 const NPC_PICK_M: f32 = 5.0;
1151 npcs.iter()
1152 .filter(|n| n.can_trade)
1153 .filter_map(|n| {
1154 let d = dist2d(x, y, n.x, n.y);
1155 if d <= NPC_PICK_M {
1156 Some((d, n.id.clone(), n.label.clone()))
1157 } else {
1158 None
1159 }
1160 })
1161 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1162 .map(|(_, id, label)| (id, label))
1163}
1164
1165pub fn owned_storage_template_ids(
1168 placed: &[PlacedContainerView],
1169 character_id: Option<uuid::Uuid>,
1170) -> Vec<String> {
1171 let Some(cid) = character_id else {
1172 return Vec::new();
1173 };
1174 let mut out: Vec<String> = placed
1175 .iter()
1176 .filter(|c| c.owner_character_id == Some(cid))
1177 .flat_map(|c| c.contents.iter().map(|s| s.template_id.clone()))
1178 .filter(|t| !t.is_empty())
1179 .collect();
1180 out.sort();
1181 out.dedup();
1182 out
1183}
1184
1185pub fn worker_craft_blueprint_ids(
1192 blueprints: &[flatland_protocol::BlueprintView],
1193 known_blueprint_ids: Option<&[String]>,
1194) -> Vec<String> {
1195 let mut ids: Vec<String> = blueprints.iter().map(|b| b.id.clone()).collect();
1196 if let Some(known) = known_blueprint_ids {
1197 if !known.is_empty() {
1198 ids.retain(|id| known.iter().any(|k| k == id));
1199 }
1200 }
1201 ids
1202}
1203
1204pub fn route_item_template_candidates(
1209 placed: &[PlacedContainerView],
1210 character_id: Option<uuid::Uuid>,
1211 inventory: &std::collections::HashMap<String, u32>,
1212 blueprints: &[flatland_protocol::BlueprintView],
1213 resource_nodes: &[flatland_protocol::ResourceNodeView],
1214 extra: &[String],
1215) -> Vec<String> {
1216 let mut out = owned_storage_template_ids(placed, character_id);
1217 for (template, qty) in inventory {
1218 if *qty > 0 && !template.is_empty() {
1219 out.push(template.clone());
1220 }
1221 }
1222 for bp in blueprints {
1223 if !bp.output.is_empty() {
1224 out.push(bp.output.clone());
1225 }
1226 for input in &bp.inputs {
1227 if !input.template_id.is_empty() {
1228 out.push(input.template_id.clone());
1229 }
1230 }
1231 for tool in &bp.required_tools {
1232 if !tool.item.is_empty() {
1233 out.push(tool.item.clone());
1234 }
1235 }
1236 }
1237 for node in resource_nodes {
1238 if !node.item_template.is_empty() {
1239 out.push(node.item_template.clone());
1240 }
1241 }
1242 for t in extra {
1243 if !t.is_empty() {
1244 out.push(t.clone());
1245 }
1246 }
1247 out.sort();
1248 out.dedup();
1249 out
1250}
1251
1252pub fn pick_storage_container_at(
1254 placed: &[PlacedContainerView],
1255 character_id: Option<uuid::Uuid>,
1256 x: f32,
1257 y: f32,
1258) -> Option<String> {
1259 let cid = character_id?;
1260 placed
1261 .iter()
1262 .filter(|c| c.owner_character_id == Some(cid))
1263 .filter(|c| c.capacity_volume.unwrap_or(0.0) > 0.0)
1264 .filter_map(|c| {
1265 let d = dist2d(x, y, c.x, c.y);
1266 if d <= STORAGE_PICK_M {
1267 Some((d, c.id.clone()))
1268 } else {
1269 None
1270 }
1271 })
1272 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1273 .map(|(_, id)| id)
1274}
1275
1276#[cfg(test)]
1277mod tests {
1278 use super::*;
1279
1280 #[test]
1281 fn worker_craft_blueprint_ids_filters_to_known_recipes() {
1282 use flatland_protocol::{BlueprintIngredientView, BlueprintView};
1283
1284 fn bp(id: &str) -> BlueprintView {
1285 BlueprintView {
1286 id: id.into(),
1287 label: id.into(),
1288 output: "x".into(),
1289 output_qty: 1,
1290 craft_ticks: 1,
1291 inputs: vec![BlueprintIngredientView {
1292 template_id: "oak_log".into(),
1293 quantity: 1,
1294 consumed: true,
1295 }],
1296 station: Some("hand".into()),
1297 category: None,
1298 required_tools: vec![],
1299 skill: None,
1300 failure_chance: 0.0,
1301 worker_train_copper: 0,
1302 }
1303 }
1304
1305 let all = vec![
1306 bp("oak_to_lumber"),
1307 bp("vegetable_soup"),
1308 bp("craft_simple_camp_bed"),
1309 bp("craft_wooden_chest_small"),
1310 bp("iron_ingot"),
1311 ];
1312 let known = vec![
1313 "oak_to_lumber".into(),
1314 "craft_simple_camp_bed".into(),
1315 "craft_wooden_chest_small".into(),
1316 ];
1317
1318 let filtered = worker_craft_blueprint_ids(&all, Some(&known));
1319 assert_eq!(
1320 filtered,
1321 vec![
1322 "oak_to_lumber",
1323 "craft_simple_camp_bed",
1324 "craft_wooden_chest_small"
1325 ]
1326 );
1327
1328 assert_eq!(
1330 worker_craft_blueprint_ids(&all, Some(&[])).len(),
1331 all.len()
1332 );
1333 assert_eq!(worker_craft_blueprint_ids(&all, None).len(), all.len());
1334 }
1335
1336 #[test]
1337 fn route_item_candidates_include_craft_outputs_not_in_storage() {
1338 use flatland_protocol::{
1339 BlueprintIngredientView, BlueprintView, PlacedContainerView, ResourceNodeView,
1340 ResourceNodeState,
1341 };
1342 use std::collections::HashMap;
1343 use uuid::Uuid;
1344
1345 let cid = Uuid::from_u128(0x1111_2222_3333_4444_5555_6666_7777_8888);
1346 let placed = vec![PlacedContainerView {
1347 id: "chest-1".into(),
1348 template_id: "wooden_chest_small".into(),
1349 display_name: "Chest".into(),
1350 x: 0.0,
1351 y: 0.0,
1352 z: 0.0,
1353 locked: false,
1354 accessible: true,
1355 owner_character_id: Some(cid),
1356 contents: vec![],
1357 lock_id: None,
1358 capacity_volume: Some(20.0),
1359 item_instance_id: None,
1360 tile_id: None,
1361 worker_lodging_capacity: None,
1362 }];
1363 let blueprints = vec![BlueprintView {
1364 id: "smelt_iron".into(),
1365 label: "Smelt Iron".into(),
1366 output: "iron_ingot".into(),
1367 output_qty: 1,
1368 craft_ticks: 30,
1369 inputs: vec![BlueprintIngredientView {
1370 template_id: "iron_ore".into(),
1371 quantity: 1,
1372 consumed: true,
1373 }],
1374 station: Some("hand".into()),
1375 category: None,
1376 required_tools: vec![],
1377 skill: None,
1378 failure_chance: 0.0,
1379 worker_train_copper: 0,
1380 }];
1381 let nodes = vec![ResourceNodeView {
1382 id: "ore-1".into(),
1383 label: "Iron Ore".into(),
1384 x: 1.0,
1385 y: 1.0,
1386 z: 0.0,
1387 item_template: "iron_ore".into(),
1388 state: ResourceNodeState::Available,
1389 blocking: true,
1390 blocking_radius_m: 0.8,
1391 tile_id: None,
1392 sprite_mode: None,
1393 presentation_state: None,
1394 }];
1395 let ids = route_item_template_candidates(
1396 &placed,
1397 Some(cid),
1398 &HashMap::new(),
1399 &blueprints,
1400 &nodes,
1401 &[],
1402 );
1403 assert!(
1404 ids.contains(&"iron_ingot".to_string()),
1405 "craft output should be selectable before any exists in storage: {ids:?}"
1406 );
1407 assert!(ids.contains(&"iron_ore".to_string()));
1408 }
1409
1410 #[test]
1411 fn build_ordered_job_yaml_includes_stops() {
1412 let mut ed = WorkerRouteEditorState::new(
1413 "worker-worker_laborer-1".into(),
1414 "Laborer".into(),
1415 Some("chest-bed".into()),
1416 );
1417 ed.append_waypoint(10.0, 20.0, 0.0);
1418 ed.append_harvest_node("oak-n1");
1419 ed.append_deposit_at("chest-storage-a");
1420 ed.append_rest_if_needed();
1421 let yaml = ed.build_job_yaml().expect("yaml");
1422 assert!(yaml.contains("kind: ordered"));
1423 assert!(yaml.contains("lodging_container_id: chest-bed"));
1424 assert!(yaml.contains("stop: waypoint"));
1425 assert!(yaml.contains("oak-n1"));
1426 assert!(yaml.contains("deposit_at"));
1427 assert!(yaml.contains("chest-storage-a"));
1428 assert!(yaml.contains("rest_if_needed"));
1429 }
1430
1431 #[test]
1432 fn requires_at_least_one_stop() {
1433 let ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1434 assert!(ed.build_job_yaml().is_err());
1435 }
1436
1437 #[test]
1438 fn reorder_stops() {
1439 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1440 ed.append_harvest_node("oak-a");
1441 ed.append_harvest_node("oak-b");
1442 ed.append_waypoint(5.0, 6.0, 0.0);
1443 ed.select_stop(1);
1445 ed.move_selected_up();
1446 assert!(
1447 matches!(&ed.stops[0], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
1448 );
1449 ed.move_selected_down();
1451 assert!(
1452 matches!(&ed.stops[1], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
1453 );
1454 }
1455
1456 #[test]
1457 fn duplicate_harvest_node_selects_existing_instead() {
1458 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1459 assert!(ed.append_harvest_node("oak-a"));
1460 ed.append_waypoint(1.0, 2.0, 0.0);
1461 assert!(!ed.append_harvest_node("oak-a"));
1462 assert_eq!(ed.stops.len(), 2);
1463 assert_eq!(ed.selected_stop_index, 0);
1464 }
1465
1466 #[test]
1467 fn duplicate_deposit_container_selects_existing_instead() {
1468 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1469 assert!(ed.append_deposit_at("chest-1"));
1470 ed.append_harvest_node("oak-a");
1471 assert!(!ed.append_deposit_at("chest-1"));
1472 assert_eq!(ed.stops.len(), 2);
1473 assert_eq!(ed.selected_stop_index, 0);
1474 }
1475
1476 #[test]
1477 fn duplicate_trade_stop_selects_existing_instead() {
1478 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1479 assert!(ed.append_trade_with("oak_log".into(), Some("ada".into()), true));
1480 assert!(!ed.append_trade_with("oak_log".into(), Some("ada".into()), true));
1481 assert!(ed.append_trade_with("lumber".into(), Some("ada".into()), true));
1483 assert_eq!(ed.stops.len(), 2);
1484 }
1485
1486 #[test]
1487 fn build_idle_job_yaml_parks_worker() {
1488 let ed = WorkerRouteEditorState::new("w1".into(), "L".into(), Some("bed-1".into()));
1489 let yaml = ed.build_idle_job_yaml();
1490 assert!(yaml.contains("mode: idle"));
1491 assert!(yaml.contains("steps: []"));
1492 assert!(!yaml.contains("route:"));
1493 }
1494
1495 #[test]
1496 fn remove_selected_stop_adjusts_index() {
1497 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1498 ed.append_waypoint(1.0, 2.0, 0.0);
1499 ed.append_harvest_node("oak-a");
1500 ed.append_deposit_at("chest-1");
1501 ed.select_stop(2);
1502 ed.remove_selected_stop();
1503 assert_eq!(ed.stops.len(), 2);
1504 assert_eq!(ed.selected_stop_index, 1);
1505 }
1506
1507 #[test]
1508 fn build_job_yaml_includes_trade_with_stop() {
1509 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1510 ed.append_trade_with("oak_log".into(), None, true);
1511 ed.append_trade_with("lumber".into(), Some("ada_broker".into()), false);
1512 let yaml = ed.build_job_yaml().expect("yaml");
1513 assert!(yaml.contains("stop: trade_with, template: oak_log, sell_all: true"));
1514 assert!(yaml.contains("npc_id: ada_broker"));
1515 assert!(yaml.contains("sell_all: false"));
1516 }
1517
1518 #[test]
1519 fn set_selected_trade_npc_updates_stop() {
1520 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1521 ed.append_trade_with("oak_log".into(), None, true);
1522 assert!(ed.set_selected_trade_npc("ada_broker".into()));
1523 assert!(matches!(&ed.stops[0], WorkerRouteStop::TradeWith { npc_id, .. } if npc_id.as_deref() == Some("ada_broker")));
1524 }
1525
1526 #[test]
1527 fn build_job_yaml_deposit_filter_round_trips() {
1528 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), Some("bed-1".into()));
1529 ed.append_deposit_at_filtered("chest-out", vec!["lumber".into()]);
1530 let yaml = ed.build_job_yaml().expect("yaml");
1531 assert!(yaml.contains("stop: deposit_at, container_id: chest-out, filter: [lumber]"));
1532 }
1533
1534 #[test]
1535 fn build_job_yaml_includes_withdraw_and_craft_stops() {
1536 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1537 ed.stops.push(WorkerRouteStop::WithdrawFrom {
1538 container_id: "chest-src".into(),
1539 items: vec![WorkerRouteWithdrawItem { template: "oak_log".into(), qty: None }],
1540 });
1541 ed.stops.push(WorkerRouteStop::WithdrawFrom {
1542 container_id: "chest-src-2".into(),
1543 items: vec![WorkerRouteWithdrawItem { template: "iron_ore".into(), qty: Some(10) }],
1544 });
1545 ed.stops.push(WorkerRouteStop::CraftAt {
1546 device: "hand".into(),
1547 blueprint: "oak_to_lumber".into(),
1548 qty: None,
1549 });
1550 ed.append_deposit_at("chest-out");
1551 let yaml = ed.build_job_yaml().expect("yaml");
1552 assert!(yaml.contains("stop: withdraw_from"));
1553 assert!(yaml.contains("container_id: chest-src"));
1554 assert!(yaml.contains("template: oak_log, all: true"));
1555 assert!(yaml.contains("template: iron_ore, qty: 10"));
1556 assert!(yaml.contains("stop: craft_at, device: hand, blueprint: oak_to_lumber"));
1557 assert!(yaml.contains("stop: deposit_at"));
1558 }
1559
1560 #[test]
1561 fn withdraw_summary_shows_all_vs_qty() {
1562 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1563 ed.stops.push(WorkerRouteStop::WithdrawFrom {
1564 container_id: "chest-src".into(),
1565 items: vec![WorkerRouteWithdrawItem { template: "oak_log".into(), qty: None }],
1566 });
1567 assert!(ed.stops[0].summary().contains("withdraw all oak_log"));
1568 }
1569
1570 #[test]
1571 fn withdraw_view_round_trips_all_flag() {
1572 let view = WorkerRouteStopView::WithdrawFrom {
1573 container_id: "chest-1".into(),
1574 items: vec![
1575 flatland_protocol::WorkerWithdrawItemView {
1576 template: "oak_log".into(),
1577 qty: 0,
1578 all: true,
1579 },
1580 flatland_protocol::WorkerWithdrawItemView {
1581 template: "iron_ore".into(),
1582 qty: 5,
1583 all: false,
1584 },
1585 ],
1586 };
1587 let stop = stop_view_to_stop(&view);
1588 let WorkerRouteStop::WithdrawFrom { items, .. } = stop else {
1589 panic!("expected withdraw stop");
1590 };
1591 assert_eq!(items[0].qty, None);
1592 assert_eq!(items[1].qty, Some(5));
1593 }
1594
1595 #[test]
1596 fn legacy_harvest_loop_route_converts_to_ordered_stops() {
1597 let route = WorkerRouteView {
1598 kind: WorkerRouteKindView::HarvestLoop,
1599 lodging_container_id: Some("bed-1".into()),
1600 outbound_waypoints: vec![flatland_protocol::WorkerRouteWaypointView {
1601 x: 1.0,
1602 y: 2.0,
1603 z: 0.0,
1604 }],
1605 harvest_nodes: vec!["oak-1".into()],
1606 carry_return_ratio: 0.9,
1607 stops: Vec::new(),
1608 };
1609 let ed = WorkerRouteEditorState::from_saved_route(
1610 "w1".into(),
1611 "L".into(),
1612 &route,
1613 None,
1614 );
1615 assert_eq!(ed.stops.len(), 4);
1617 assert!(matches!(&ed.stops[2], WorkerRouteStop::DepositAt { container_id, .. } if container_id == "bed-1"));
1618 assert!(matches!(&ed.stops[3], WorkerRouteStop::RestIfNeeded));
1619 }
1620
1621 #[test]
1624 fn retarget_withdraw_container_updates_editing_stop() {
1625 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1626 ed.stops.push(WorkerRouteStop::WithdrawFrom {
1627 container_id: "chest-old".into(),
1628 items: vec![WorkerRouteWithdrawItem {
1629 template: "iron_ore".into(),
1630 qty: None,
1631 }],
1632 });
1633 ed.selected_stop_index = 0;
1634 ed.editing_index = Some(0);
1635 assert!(ed.retarget_withdraw_container("chest-new".into()));
1636 assert!(matches!(
1637 &ed.stops[0],
1638 WorkerRouteStop::WithdrawFrom { container_id, .. } if container_id == "chest-new"
1639 ));
1640 }
1641
1642 #[test]
1643 fn summary_resolved_uses_friendly_labels() {
1644 let stop = WorkerRouteStop::WithdrawFrom {
1645 container_id: "uuid-iron".into(),
1646 items: vec![WorkerRouteWithdrawItem {
1647 template: "iron_ore".into(),
1648 qty: None,
1649 }],
1650 };
1651 let summary = stop.summary_resolved(
1652 |_| "Iron Ore Container".into(),
1653 |_| "Ada".into(),
1654 |_| "Oak Tree".into(),
1655 );
1656 assert_eq!(summary, "withdraw all iron_ore from Iron Ore Container");
1657 assert!(!summary.contains("uuid"));
1658 }
1659
1660 #[test]
1661 fn sheet_back_walks_up_hierarchy() {
1662 use RouteEditorSheet as S;
1663 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1664 assert_eq!(ed.sheet, S::Stops);
1665 ed.open_add_menu();
1666 assert_eq!(ed.sheet, S::AddMenu { index: 0 });
1667 ed.open_sheet(S::WithdrawContainers { index: 0 });
1668 ed.open_sheet(S::WithdrawItems {
1669 container_id: "c1".into(),
1670 lines: vec![],
1671 index: 0,
1672 });
1673 ed.sheet_back();
1674 assert_eq!(ed.sheet, S::WithdrawContainers { index: 0 });
1675 ed.sheet_back();
1676 assert_eq!(ed.sheet, S::AddMenu { index: 0 });
1677 ed.sheet_back();
1678 assert_eq!(ed.sheet, S::Stops);
1679 ed.sheet_back();
1681 assert_eq!(ed.sheet, S::Stops);
1682 }
1683
1684 #[test]
1685 fn sheet_back_while_editing_returns_to_stops() {
1686 use RouteEditorSheet as S;
1687 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1688 ed.append_harvest_node("oak-a");
1689 ed.begin_edit_selected();
1690 ed.open_sheet(S::HarvestPicker { index: 0 });
1691 ed.sheet_back();
1692 assert_eq!(ed.sheet, S::Stops);
1693 assert_eq!(ed.editing_index, None);
1694 }
1695
1696 #[test]
1697 fn sheet_back_while_editing_withdraw_keeps_edit_on_container_picker() {
1698 use RouteEditorSheet as S;
1699 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1700 ed.stops.push(WorkerRouteStop::WithdrawFrom {
1701 container_id: "chest-a".into(),
1702 items: vec![WorkerRouteWithdrawItem {
1703 template: "oak_log".into(),
1704 qty: None,
1705 }],
1706 });
1707 ed.begin_edit_selected();
1708 ed.open_sheet(S::WithdrawItems {
1709 container_id: "chest-a".into(),
1710 lines: vec![],
1711 index: 0,
1712 });
1713 ed.sheet_back();
1714 assert!(matches!(ed.sheet, S::WithdrawContainers { .. }));
1715 assert_eq!(ed.editing_index, Some(0), "still editing after back to picker");
1716 ed.sheet_back();
1717 assert_eq!(ed.sheet, S::Stops);
1718 assert_eq!(ed.editing_index, None);
1719 }
1720
1721 #[test]
1722 fn confirm_stop_replaces_withdraw_container_when_editing() {
1723 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1724 ed.stops.push(WorkerRouteStop::WithdrawFrom {
1725 container_id: "chest-old".into(),
1726 items: vec![WorkerRouteWithdrawItem {
1727 template: "oak_log".into(),
1728 qty: None,
1729 }],
1730 });
1731 ed.stops.push(WorkerRouteStop::RestIfNeeded);
1732 ed.select_stop(0);
1733 ed.begin_edit_selected();
1734 assert!(ed.confirm_stop(WorkerRouteStop::WithdrawFrom {
1735 container_id: "chest-new".into(),
1736 items: vec![WorkerRouteWithdrawItem {
1737 template: "oak_log".into(),
1738 qty: None,
1739 }],
1740 }));
1741 assert_eq!(ed.stops.len(), 2);
1742 assert!(matches!(
1743 &ed.stops[0],
1744 WorkerRouteStop::WithdrawFrom { container_id, .. } if container_id == "chest-new"
1745 ));
1746 }
1747
1748 #[test]
1749 fn confirm_stop_replaces_when_editing() {
1750 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1751 ed.append_harvest_node("oak-a");
1752 ed.append_waypoint(1.0, 1.0, 0.0);
1753 ed.select_stop(0);
1754 ed.begin_edit_selected();
1755 assert!(ed.confirm_stop(WorkerRouteStop::HarvestNode {
1756 node_id: "oak-b".into()
1757 }));
1758 assert_eq!(ed.stops.len(), 2, "edit replaces in place, no append");
1759 assert!(
1760 matches!(&ed.stops[0], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
1761 );
1762 assert_eq!(ed.sheet, RouteEditorSheet::Stops);
1763 assert_eq!(ed.editing_index, None);
1764 }
1765
1766 #[test]
1767 fn confirm_stop_dedupes_on_append() {
1768 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1769 ed.append_harvest_node("oak-a");
1770 assert!(!ed.confirm_stop(WorkerRouteStop::HarvestNode {
1771 node_id: "oak-a".into()
1772 }));
1773 assert_eq!(ed.stops.len(), 1);
1774 assert_eq!(ed.selected_stop_index, 0);
1775 }
1776
1777 #[test]
1778 fn withdraw_line_cycle_and_collect() {
1779 let contents = vec![
1780 ItemStack {
1781 template_id: "oak_log".into(),
1782 quantity: 12,
1783 ..Default::default()
1784 },
1785 ItemStack {
1786 template_id: "lumber".into(),
1787 quantity: 4,
1788 ..Default::default()
1789 },
1790 ];
1791 let mut lines =
1792 WorkerRouteEditorState::withdraw_line_drafts(&contents, &[]);
1793 assert_eq!(lines.len(), 2);
1794 lines[1].cycle();
1796 assert_eq!(lines[1].mode, WithdrawLineMode::All);
1797 lines[0].cycle();
1798 lines[0].cycle();
1799 assert!(matches!(lines[0].mode, WithdrawLineMode::Qty(_)));
1800 lines[0].adjust_qty(5);
1801 let items = WorkerRouteEditorState::withdraw_items_from_lines(&lines);
1802 assert_eq!(items.len(), 2);
1803 assert_eq!(items[0].template, "lumber");
1804 assert_eq!(items[0].qty, Some(4));
1806 assert_eq!(items[1].qty, None);
1807 }
1808
1809 #[test]
1810 fn withdraw_drafts_prefill_existing_and_keep_missing() {
1811 let contents = vec![ItemStack {
1812 template_id: "oak_log".into(),
1813 quantity: 3,
1814 ..Default::default()
1815 }];
1816 let existing = vec![
1817 WorkerRouteWithdrawItem { template: "oak_log".into(), qty: None },
1818 WorkerRouteWithdrawItem { template: "iron_ore".into(), qty: Some(5) },
1819 ];
1820 let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
1821 assert_eq!(lines.len(), 2);
1822 let ore = lines.iter().find(|l| l.template == "iron_ore").expect("ore line");
1823 assert_eq!(ore.available, 0, "missing template kept with 0 available");
1824 assert_eq!(ore.mode, WithdrawLineMode::Qty(5));
1825 let oak = lines.iter().find(|l| l.template == "oak_log").expect("oak line");
1826 assert_eq!(oak.mode, WithdrawLineMode::All);
1827 }
1828}