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 owned_container_candidates_with_occupants(placed, character_id, px, py, &[])
299}
300
301pub fn owned_container_candidates_with_occupants(
303 placed: &[PlacedContainerView],
304 character_id: Option<uuid::Uuid>,
305 px: f32,
306 py: f32,
307 hired: &[flatland_protocol::HiredWorkerView],
308) -> Vec<ContainerCandidate> {
309 let Some(cid) = character_id else {
310 return Vec::new();
311 };
312 let mut out: Vec<ContainerCandidate> = placed
313 .iter()
314 .filter(|c| c.owner_character_id == Some(cid))
315 .filter(|c| {
316 c.capacity_volume.unwrap_or(0.0) > 0.0 || c.worker_lodging_capacity.unwrap_or(0) > 0
317 })
318 .map(|c| {
319 let is_lodging = c.worker_lodging_capacity.unwrap_or(0) > 0;
320 let mut summary = summarize_contents(&c.contents);
321 if is_lodging {
322 let who = lodging_occupants_for(hired, &c.id);
323 let who = if who.is_empty() {
324 "vacant".into()
325 } else {
326 who.join(", ")
327 };
328 summary = format!("lodged: {who} · {summary}");
329 }
330 ContainerCandidate {
331 id: c.id.clone(),
332 name: c.display_name.clone(),
333 is_lodging,
334 summary,
335 dist: dist2d(px, py, c.x, c.y),
336 }
337 })
338 .collect();
339 out.sort_by(|a, b| {
340 a.dist
341 .partial_cmp(&b.dist)
342 .unwrap_or(std::cmp::Ordering::Equal)
343 .then_with(|| a.name.cmp(&b.name))
344 .then_with(|| a.id.cmp(&b.id))
345 });
346 out
347}
348
349fn lodging_occupants_for(
350 hired: &[flatland_protocol::HiredWorkerView],
351 container_id: &str,
352) -> Vec<String> {
353 let mut names: Vec<String> = hired
354 .iter()
355 .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
356 .map(|w| w.label.clone())
357 .collect();
358 names.sort();
359 names
360}
361
362#[derive(Debug, Clone, PartialEq)]
364pub struct NodeCandidate {
365 pub id: String,
366 pub label: String,
367 pub template: String,
368 pub dist: f32,
369}
370
371pub fn node_candidates(nodes: &[ResourceNodeView], px: f32, py: f32) -> Vec<NodeCandidate> {
373 let mut out: Vec<NodeCandidate> = nodes
374 .iter()
375 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
376 .map(|n| NodeCandidate {
377 id: n.id.clone(),
378 label: n.label.clone(),
379 template: n.item_template.clone(),
380 dist: dist2d(px, py, n.x, n.y),
381 })
382 .collect();
383 out.sort_by(|a, b| {
384 a.dist
385 .partial_cmp(&b.dist)
386 .unwrap_or(std::cmp::Ordering::Equal)
387 .then_with(|| a.id.cmp(&b.id))
388 });
389 out
390}
391
392#[derive(Debug, Clone, PartialEq)]
394pub struct TradeNpcCandidate {
395 pub id: String,
396 pub label: String,
397 pub dist: f32,
398}
399
400pub fn trade_npc_candidates(npcs: &[NpcView], px: f32, py: f32) -> Vec<TradeNpcCandidate> {
402 let mut out: Vec<TradeNpcCandidate> = npcs
403 .iter()
404 .filter(|n| n.can_trade)
405 .map(|n| TradeNpcCandidate {
406 id: n.id.clone(),
407 label: n.label.clone(),
408 dist: dist2d(px, py, n.x, n.y),
409 })
410 .collect();
411 out.sort_by(|a, b| {
412 a.dist
413 .partial_cmp(&b.dist)
414 .unwrap_or(std::cmp::Ordering::Equal)
415 .then_with(|| a.id.cmp(&b.id))
416 });
417 out
418}
419
420#[derive(Debug, Clone)]
425pub struct WorkerRouteEditorState {
426 pub worker_instance_id: String,
427 pub worker_label: String,
428 pub lodging_container_id: Option<String>,
429 pub stops: Vec<WorkerRouteStop>,
431 pub selected_stop_index: usize,
433 pub carry_return_ratio: f32,
435 pub sheet: RouteEditorSheet,
437 pub editing_index: Option<usize>,
440 pub panel_collapsed: bool,
442}
443
444impl WorkerRouteEditorState {
445 pub fn new(
446 worker_instance_id: String,
447 worker_label: String,
448 lodging_container_id: Option<String>,
449 ) -> Self {
450 Self {
451 worker_instance_id,
452 worker_label,
453 lodging_container_id,
454 stops: Vec::new(),
455 selected_stop_index: 0,
456 carry_return_ratio: 0.90,
457 sheet: RouteEditorSheet::Stops,
458 editing_index: None,
459 panel_collapsed: false,
460 }
461 }
462
463 pub fn toggle_panel_collapsed(&mut self) {
464 self.panel_collapsed = !self.panel_collapsed;
465 }
466
467 pub fn from_saved_route(
468 worker_instance_id: String,
469 worker_label: String,
470 route: &WorkerRouteView,
471 lodging_fallback: Option<String>,
472 ) -> Self {
473 let lodging = route
474 .lodging_container_id
475 .clone()
476 .or(lodging_fallback);
477
478 match route.kind {
479 WorkerRouteKindView::Ordered => Self {
480 worker_instance_id,
481 worker_label,
482 lodging_container_id: lodging,
483 stops: route
484 .stops
485 .iter()
486 .map(stop_view_to_stop)
487 .collect(),
488 selected_stop_index: 0,
489 carry_return_ratio: route.carry_return_ratio,
490 sheet: RouteEditorSheet::Stops,
491 editing_index: None,
492 panel_collapsed: false,
493 },
494 WorkerRouteKindView::HarvestLoop => {
495 let mut stops = Vec::new();
500 for wp in &route.outbound_waypoints {
501 stops.push(WorkerRouteStop::Waypoint {
502 x: wp.x,
503 y: wp.y,
504 z: wp.z,
505 });
506 }
507 for node in &route.harvest_nodes {
508 stops.push(WorkerRouteStop::HarvestNode {
509 node_id: node.clone(),
510 });
511 }
512 if let Some(lodging) = &lodging {
513 stops.push(WorkerRouteStop::DepositAt {
514 container_id: lodging.clone(),
515 filter: None,
516 });
517 stops.push(WorkerRouteStop::RestIfNeeded);
518 }
519 Self {
520 worker_instance_id,
521 worker_label,
522 lodging_container_id: lodging,
523 stops,
524 selected_stop_index: 0,
525 carry_return_ratio: route.carry_return_ratio,
526 sheet: RouteEditorSheet::Stops,
527 editing_index: None,
528 panel_collapsed: false,
529 }
530 }
531 }
532 }
533
534 pub fn stop_count(&self) -> usize {
537 self.stops.len()
538 }
539
540 pub fn select_stop(&mut self, index: usize) {
541 if self.stops.is_empty() {
542 self.selected_stop_index = 0;
543 return;
544 }
545 self.selected_stop_index = index.min(self.stops.len() - 1);
546 }
547
548 pub fn move_selected_up(&mut self) {
549 if self.selected_stop_index == 0 {
550 return;
551 }
552 self.stops
553 .swap(self.selected_stop_index, self.selected_stop_index - 1);
554 self.selected_stop_index -= 1;
555 }
556
557 pub fn move_selected_down(&mut self) {
558 if self.selected_stop_index + 1 >= self.stops.len() {
559 return;
560 }
561 self.stops
562 .swap(self.selected_stop_index, self.selected_stop_index + 1);
563 self.selected_stop_index += 1;
564 }
565
566 pub fn remove_selected_stop(&mut self) {
567 if self.stops.is_empty() {
568 return;
569 }
570 let idx = self.selected_stop_index.min(self.stops.len() - 1);
571 self.stops.remove(idx);
572 self.editing_index = None;
573 if self.selected_stop_index >= self.stops.len() {
574 self.selected_stop_index = self.stops.len().saturating_sub(1);
575 }
576 }
577
578 fn find_stop(&self, pred: impl Fn(&WorkerRouteStop) -> bool) -> Option<usize> {
580 self.stops.iter().position(pred)
581 }
582
583 pub fn harvest_node_index(&self, node_id: &str) -> Option<usize> {
584 self.find_stop(
585 |s| matches!(s, WorkerRouteStop::HarvestNode { node_id: n } if n == node_id),
586 )
587 }
588
589 pub fn deposit_container_index(&self, container_id: &str) -> Option<usize> {
590 self.find_stop(
591 |s| matches!(s, WorkerRouteStop::DepositAt { container_id: c, .. } if c == container_id),
592 )
593 }
594
595 pub fn trade_stop_index(&self, npc_id: Option<&str>, template: &str) -> Option<usize> {
596 self.find_stop(|s| {
597 matches!(s, WorkerRouteStop::TradeWith { npc_id: n, template: t, .. }
598 if n.as_deref() == npc_id && t == template)
599 })
600 }
601
602 pub fn insert_stop(&mut self, stop: WorkerRouteStop) -> (bool, usize) {
606 let existing = match &stop {
607 WorkerRouteStop::HarvestNode { node_id } => self.harvest_node_index(node_id),
608 WorkerRouteStop::DepositAt { container_id, .. } => {
609 self.deposit_container_index(container_id)
610 }
611 WorkerRouteStop::TradeWith { npc_id, template, .. } => {
612 self.trade_stop_index(npc_id.as_deref(), template)
613 }
614 _ => None,
615 };
616 if let Some(idx) = existing {
617 self.selected_stop_index = idx;
618 return (false, idx);
619 }
620 self.stops.push(stop);
621 self.selected_stop_index = self.stops.len() - 1;
622 (true, self.stops.len() - 1)
623 }
624
625 pub fn append_waypoint(&mut self, x: f32, y: f32, z: f32) {
626 self.insert_stop(WorkerRouteStop::Waypoint { x, y, z });
627 }
628
629 pub fn append_harvest_node(&mut self, node_id: &str) -> bool {
630 self.insert_stop(WorkerRouteStop::HarvestNode {
631 node_id: node_id.to_string(),
632 })
633 .0
634 }
635
636 pub fn append_deposit_at(&mut self, container_id: &str) -> bool {
637 self.insert_stop(WorkerRouteStop::DepositAt {
638 container_id: container_id.to_string(),
639 filter: None,
640 })
641 .0
642 }
643
644 pub fn append_deposit_at_filtered(&mut self, container_id: &str, filter_templates: Vec<String>) {
647 self.stops.push(WorkerRouteStop::DepositAt {
648 container_id: container_id.to_string(),
649 filter: Some(filter_templates),
650 });
651 self.selected_stop_index = self.stops.len() - 1;
652 }
653
654 pub fn append_rest_if_needed(&mut self) {
655 self.insert_stop(WorkerRouteStop::RestIfNeeded);
656 }
657
658 pub fn append_wait(&mut self, wait_ticks: u64) {
659 self.insert_stop(WorkerRouteStop::Wait { wait_ticks });
660 }
661
662 pub fn append_trade_with(&mut self, template: String, npc_id: Option<String>, sell_all: bool) -> bool {
666 self.insert_stop(WorkerRouteStop::TradeWith {
667 npc_id,
668 template,
669 sell_all,
670 })
671 .0
672 }
673
674 pub fn set_selected_trade_npc(&mut self, npc_id: String) -> bool {
679 let Some(stop) = self.stops.get_mut(self.selected_stop_index) else {
680 return false;
681 };
682 let WorkerRouteStop::TradeWith { npc_id: slot, template, .. } = stop else {
683 return false;
684 };
685 *slot = Some(npc_id.clone());
686 let template = template.clone();
687 let selected = self.selected_stop_index;
688 if let Some(other) = self
689 .trade_stop_index(Some(npc_id.as_str()), template.as_str())
690 .filter(|&i| i != selected)
691 {
692 self.stops.remove(selected);
693 self.selected_stop_index = if other > selected { other - 1 } else { other };
694 }
695 true
696 }
697
698 pub fn set_selected_withdraw_container(&mut self, container_id: String) -> bool {
702 let Some(stop) = self.stops.get_mut(self.selected_stop_index) else {
703 return false;
704 };
705 if let WorkerRouteStop::WithdrawFrom { container_id: slot, .. } = stop {
706 *slot = container_id;
707 return true;
708 }
709 false
710 }
711
712 pub fn retarget_withdraw_container(&mut self, container_id: String) -> bool {
715 let idx = self.editing_index.unwrap_or(self.selected_stop_index);
716 let Some(stop) = self.stops.get_mut(idx) else {
717 return false;
718 };
719 if let WorkerRouteStop::WithdrawFrom { container_id: slot, .. } = stop {
720 *slot = container_id;
721 return true;
722 }
723 false
724 }
725
726 pub fn retarget_deposit_container(&mut self, container_id: String) -> bool {
729 let idx = self.editing_index.unwrap_or(self.selected_stop_index);
730 let Some(stop) = self.stops.get_mut(idx) else {
731 return false;
732 };
733 if let WorkerRouteStop::DepositAt { container_id: slot, .. } = stop {
734 *slot = container_id;
735 return true;
736 }
737 false
738 }
739
740 pub fn open_add_menu(&mut self) {
743 self.editing_index = None;
744 self.sheet = RouteEditorSheet::AddMenu { index: 0 };
745 }
746
747 pub fn open_sheet(&mut self, sheet: RouteEditorSheet) {
748 self.sheet = sheet;
749 }
750
751 pub fn begin_edit_selected(&mut self) {
754 if self.selected_stop_index < self.stops.len() {
755 self.editing_index = Some(self.selected_stop_index);
756 }
757 }
758
759 pub fn sheet_back(&mut self) {
763 use RouteEditorSheet as S;
764 let editing = self.editing_index.is_some();
765 let next = match &self.sheet {
766 S::Stops => return,
767 S::AddMenu { .. } | S::BedPicker { .. } => S::Stops,
768 S::WaypointMapPick => {
769 if editing {
770 S::Stops
771 } else {
772 S::WaypointMenu { index: 0 }
773 }
774 }
775 S::WithdrawItems { .. } => S::WithdrawContainers { index: 0 },
778 S::DepositFilter { .. } => S::DepositContainers { index: 0 },
779 S::SellItem { .. } => S::SellNpcs { index: 0 },
780 S::WithdrawContainers { .. }
781 | S::DepositContainers { .. }
782 | S::SellNpcs { .. }
783 if editing =>
784 {
785 S::Stops
786 }
787 _ => {
789 if editing {
790 S::Stops
791 } else {
792 S::AddMenu { index: 0 }
793 }
794 }
795 };
796 if matches!(next, S::Stops) {
797 self.editing_index = None;
798 }
799 self.sheet = next;
800 }
801
802 pub fn confirm_stop(&mut self, stop: WorkerRouteStop) -> bool {
806 let result = if let Some(idx) = self.editing_index.take() {
807 if idx < self.stops.len() {
808 self.stops[idx] = stop;
809 self.selected_stop_index = idx;
810 }
811 true
812 } else {
813 self.insert_stop(stop).0
814 };
815 self.sheet = RouteEditorSheet::Stops;
816 result
817 }
818
819 pub fn withdraw_line_drafts(
823 contents: &[ItemStack],
824 existing: &[WorkerRouteWithdrawItem],
825 ) -> Vec<WithdrawLineDraft> {
826 let mut lines: Vec<WithdrawLineDraft> = Vec::new();
827 for s in contents {
828 if s.template_id.is_empty() {
829 continue;
830 }
831 match lines.iter_mut().find(|l| l.template == s.template_id) {
832 Some(l) => l.available += s.quantity,
833 None => lines.push(WithdrawLineDraft {
834 template: s.template_id.clone(),
835 available: s.quantity,
836 mode: WithdrawLineMode::Off,
837 }),
838 }
839 }
840 for item in existing {
841 let mode = match item.qty {
842 None => WithdrawLineMode::All,
843 Some(q) => WithdrawLineMode::Qty(q),
844 };
845 match lines.iter_mut().find(|l| l.template == item.template) {
846 Some(l) => l.mode = mode,
847 None => lines.push(WithdrawLineDraft {
848 template: item.template.clone(),
849 available: 0,
850 mode,
851 }),
852 }
853 }
854 lines.sort_by(|a, b| a.template.cmp(&b.template));
855 lines
856 }
857
858 pub fn withdraw_items_from_lines(lines: &[WithdrawLineDraft]) -> Vec<WorkerRouteWithdrawItem> {
860 lines
861 .iter()
862 .filter_map(|l| match l.mode {
863 WithdrawLineMode::Off => None,
864 WithdrawLineMode::All => Some(WorkerRouteWithdrawItem {
865 template: l.template.clone(),
866 qty: None,
867 }),
868 WithdrawLineMode::Qty(q) => Some(WorkerRouteWithdrawItem {
869 template: l.template.clone(),
870 qty: Some(q),
871 }),
872 })
873 .collect()
874 }
875
876 fn job_id(&self) -> String {
879 format!(
880 "route_{}",
881 self.worker_instance_id
882 .chars()
883 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
884 .collect::<String>()
885 )
886 }
887
888 pub fn build_idle_job_yaml(&self) -> String {
892 let job_id = self.job_id();
895 [format!("job_id: {job_id}"), "mode: idle".into(), "steps: []".into()].join("\n")
896 }
897
898 pub fn build_job_yaml(&self) -> Result<String, String> {
899 if self.stops.is_empty() {
900 return Err("add at least one stop (waypoint, harvest node, or deposit)".into());
901 }
902 let job_id = self.job_id();
903 let mut lines = vec![
904 format!("job_id: {job_id}"),
905 "mode: job_loop".into(),
906 "route:".into(),
907 " kind: ordered".into(),
908 ];
909 if let Some(lodging) = &self.lodging_container_id {
910 lines.push(format!(" lodging_container_id: {lodging}"));
911 }
912 lines.push(format!(
913 " carry_return_ratio: {:.2}",
914 self.carry_return_ratio
915 ));
916 lines.push(" stops:".into());
917 for stop in &self.stops {
918 match stop {
919 WorkerRouteStop::Waypoint { x, y, z } => {
920 lines.push(format!(
921 " - {{ stop: waypoint, x: {:.1}, y: {:.1}, z: {:.1} }}",
922 x, y, z
923 ));
924 }
925 WorkerRouteStop::HarvestNode { node_id } => {
926 lines.push(format!(" - {{ stop: harvest_node, node_id: {node_id} }}"));
927 }
928 WorkerRouteStop::DepositAt { container_id, filter } => {
929 let f = filter
930 .as_ref()
931 .filter(|f| !f.is_empty())
932 .map(|f| format!(", filter: [{}]", f.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")))
933 .unwrap_or_default();
934 lines.push(format!(
935 " - {{ stop: deposit_at, container_id: {container_id}{f} }}"
936 ));
937 }
938 WorkerRouteStop::RestIfNeeded => {
939 lines.push(" - { stop: rest_if_needed }".into());
940 }
941 WorkerRouteStop::Wait { wait_ticks } => {
942 lines.push(format!(" - {{ stop: wait, wait_ticks: {wait_ticks} }}"));
943 }
944 WorkerRouteStop::TradeWith {
945 npc_id,
946 template,
947 sell_all,
948 } => {
949 let who = npc_id
950 .as_deref()
951 .map(|n| format!(", npc_id: {n}"))
952 .unwrap_or_default();
953 lines.push(format!(
954 " - {{ stop: trade_with, template: {template}{who}, sell_all: {sell_all} }}"
955 ));
956 }
957 WorkerRouteStop::WithdrawFrom { container_id, items } => {
958 let mut block = format!(
959 " - stop: withdraw_from\n container_id: {container_id}\n items:"
960 );
961 for it in items {
962 let line = match it.qty {
963 None => format!("\n - {{ template: {}, all: true }}", it.template),
964 Some(q) => format!("\n - {{ template: {}, qty: {} }}", it.template, q),
965 };
966 block.push_str(&line);
967 }
968 lines.push(block);
969 }
970 WorkerRouteStop::CraftAt {
971 device,
972 blueprint,
973 qty,
974 } => {
975 let qty_str = qty
976 .map(|q| format!(", qty: {q}"))
977 .unwrap_or_default();
978 lines.push(format!(
979 " - {{ stop: craft_at, device: {device}, blueprint: {blueprint}{qty_str} }}"
980 ));
981 }
982 }
983 }
984 lines.push("steps: []".into());
985 Ok(lines.join("\n"))
986 }
987
988 pub fn to_route_view(&self) -> flatland_protocol::WorkerRouteView {
990 use flatland_protocol::{
991 WorkerRouteKindView, WorkerRouteStopView, WorkerRouteView, WorkerWithdrawItemView,
992 };
993 WorkerRouteView {
994 kind: WorkerRouteKindView::Ordered,
995 lodging_container_id: self.lodging_container_id.clone(),
996 outbound_waypoints: Vec::new(),
997 harvest_nodes: Vec::new(),
998 carry_return_ratio: self.carry_return_ratio,
999 stops: self
1000 .stops
1001 .iter()
1002 .map(|stop| match stop {
1003 WorkerRouteStop::Waypoint { x, y, z } => WorkerRouteStopView::Waypoint {
1004 x: *x,
1005 y: *y,
1006 z: *z,
1007 },
1008 WorkerRouteStop::HarvestNode { node_id } => WorkerRouteStopView::HarvestNode {
1009 node_id: node_id.clone(),
1010 },
1011 WorkerRouteStop::DepositAt { container_id, filter } => {
1012 WorkerRouteStopView::DepositAt {
1013 container_id: container_id.clone(),
1014 filter: filter.clone(),
1015 }
1016 }
1017 WorkerRouteStop::TradeWith {
1018 npc_id,
1019 template,
1020 sell_all,
1021 } => WorkerRouteStopView::TradeWith {
1022 npc_id: npc_id.clone(),
1023 template: template.clone(),
1024 sell_all: *sell_all,
1025 },
1026 WorkerRouteStop::WithdrawFrom { container_id, items } => {
1027 WorkerRouteStopView::WithdrawFrom {
1028 container_id: container_id.clone(),
1029 items: items
1030 .iter()
1031 .map(|i| WorkerWithdrawItemView {
1032 template: i.template.clone(),
1033 qty: i.qty.unwrap_or(0),
1034 all: i.qty.is_none(),
1035 })
1036 .collect(),
1037 }
1038 }
1039 WorkerRouteStop::CraftAt {
1040 device,
1041 blueprint,
1042 qty,
1043 } => WorkerRouteStopView::CraftAt {
1044 device: device.clone(),
1045 blueprint: blueprint.clone(),
1046 qty: *qty,
1047 },
1048 WorkerRouteStop::RestIfNeeded => WorkerRouteStopView::RestIfNeeded,
1049 WorkerRouteStop::Wait { wait_ticks } => WorkerRouteStopView::Wait {
1050 wait_ticks: *wait_ticks,
1051 },
1052 })
1053 .collect(),
1054 }
1055 }
1056}
1057
1058fn stop_view_to_stop(view: &WorkerRouteStopView) -> WorkerRouteStop {
1059 match view {
1060 WorkerRouteStopView::Waypoint { x, y, z } => WorkerRouteStop::Waypoint {
1061 x: *x,
1062 y: *y,
1063 z: *z,
1064 },
1065 WorkerRouteStopView::HarvestNode { node_id } => WorkerRouteStop::HarvestNode {
1066 node_id: node_id.clone(),
1067 },
1068 WorkerRouteStopView::DepositAt { container_id, filter } => WorkerRouteStop::DepositAt {
1069 container_id: container_id.clone(),
1070 filter: filter.clone(),
1071 },
1072 WorkerRouteStopView::TradeWith {
1073 npc_id,
1074 template,
1075 sell_all,
1076 } => WorkerRouteStop::TradeWith {
1077 npc_id: npc_id.clone(),
1078 template: template.clone(),
1079 sell_all: *sell_all,
1080 },
1081 WorkerRouteStopView::WithdrawFrom { container_id, items } => WorkerRouteStop::WithdrawFrom {
1082 container_id: container_id.clone(),
1083 items: items
1084 .iter()
1085 .map(|i| WorkerRouteWithdrawItem {
1086 template: i.template.clone(),
1087 qty: if i.all { None } else { Some(i.qty) },
1088 })
1089 .collect(),
1090 },
1091 WorkerRouteStopView::CraftAt {
1092 device,
1093 blueprint,
1094 qty,
1095 } => WorkerRouteStop::CraftAt {
1096 device: device.clone(),
1097 blueprint: blueprint.clone(),
1098 qty: *qty,
1099 },
1100 WorkerRouteStopView::RestIfNeeded => WorkerRouteStop::RestIfNeeded,
1101 WorkerRouteStopView::Wait { wait_ticks } => WorkerRouteStop::Wait {
1102 wait_ticks: *wait_ticks,
1103 },
1104 }
1105}
1106
1107const HARVEST_NODE_PICK_M: f32 = 4.0;
1110const LODGING_PICK_M: f32 = 5.0;
1111const STORAGE_PICK_M: f32 = 5.0;
1112
1113fn dist2d(x0: f32, y0: f32, x1: f32, y1: f32) -> f32 {
1114 let dx = x0 - x1;
1115 let dy = y0 - y1;
1116 (dx * dx + dy * dy).sqrt()
1117}
1118
1119pub fn pick_resource_node_at<'a>(
1121 nodes: &'a [ResourceNodeView],
1122 x: f32,
1123 y: f32,
1124) -> Option<&'a ResourceNodeView> {
1125 nodes
1126 .iter()
1127 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
1128 .filter_map(|n| {
1129 let d = dist2d(x, y, n.x, n.y);
1130 if d <= HARVEST_NODE_PICK_M {
1131 Some((d, n))
1132 } else {
1133 None
1134 }
1135 })
1136 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1137 .map(|(_, n)| n)
1138}
1139
1140pub fn owned_lodging_container_ids(
1142 placed: &[PlacedContainerView],
1143 character_id: Option<uuid::Uuid>,
1144) -> Vec<(String, String)> {
1145 owned_lodging_container_ids_with_occupants(placed, character_id, &[])
1146}
1147
1148pub fn owned_lodging_container_ids_with_occupants(
1150 placed: &[PlacedContainerView],
1151 character_id: Option<uuid::Uuid>,
1152 hired: &[flatland_protocol::HiredWorkerView],
1153) -> Vec<(String, String)> {
1154 let Some(cid) = character_id else {
1155 return Vec::new();
1156 };
1157 let mut out: Vec<(String, String)> = placed
1158 .iter()
1159 .filter(|c| c.worker_lodging_capacity.unwrap_or(0) > 0)
1160 .filter(|c| c.owner_character_id == Some(cid))
1161 .map(|c| {
1162 let who = lodging_occupants_for(hired, &c.id);
1163 let name = if who.is_empty() {
1164 format!("{} — vacant", c.display_name)
1165 } else {
1166 format!("{} — {}", c.display_name, who.join(", "))
1167 };
1168 (c.id.clone(), name)
1169 })
1170 .collect();
1171 out.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
1172 out
1173}
1174
1175pub fn pick_lodging_container_at(
1176 placed: &[PlacedContainerView],
1177 character_id: Option<uuid::Uuid>,
1178 x: f32,
1179 y: f32,
1180) -> Option<String> {
1181 let cid = character_id?;
1182 placed
1183 .iter()
1184 .filter(|c| c.worker_lodging_capacity.unwrap_or(0) > 0)
1185 .filter(|c| c.owner_character_id == Some(cid))
1186 .filter_map(|c| {
1187 let d = dist2d(x, y, c.x, c.y);
1188 if d <= LODGING_PICK_M {
1189 Some((d, c.id.clone()))
1190 } else {
1191 None
1192 }
1193 })
1194 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1195 .map(|(_, id)| id)
1196}
1197
1198pub fn pick_trade_npc_at(
1200 npcs: &[NpcView],
1201 x: f32,
1202 y: f32,
1203) -> Option<(String, String)> {
1204 const NPC_PICK_M: f32 = 5.0;
1205 npcs.iter()
1206 .filter(|n| n.can_trade)
1207 .filter_map(|n| {
1208 let d = dist2d(x, y, n.x, n.y);
1209 if d <= NPC_PICK_M {
1210 Some((d, n.id.clone(), n.label.clone()))
1211 } else {
1212 None
1213 }
1214 })
1215 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1216 .map(|(_, id, label)| (id, label))
1217}
1218
1219pub fn owned_storage_template_ids(
1222 placed: &[PlacedContainerView],
1223 character_id: Option<uuid::Uuid>,
1224) -> Vec<String> {
1225 let Some(cid) = character_id else {
1226 return Vec::new();
1227 };
1228 let mut out: Vec<String> = placed
1229 .iter()
1230 .filter(|c| c.owner_character_id == Some(cid))
1231 .flat_map(|c| c.contents.iter().map(|s| s.template_id.clone()))
1232 .filter(|t| !t.is_empty())
1233 .collect();
1234 out.sort();
1235 out.dedup();
1236 out
1237}
1238
1239pub fn worker_craft_blueprint_ids(
1246 blueprints: &[flatland_protocol::BlueprintView],
1247 known_blueprint_ids: Option<&[String]>,
1248) -> Vec<String> {
1249 let mut ids: Vec<String> = blueprints.iter().map(|b| b.id.clone()).collect();
1250 if let Some(known) = known_blueprint_ids {
1251 if !known.is_empty() {
1252 ids.retain(|id| known.iter().any(|k| k == id));
1253 }
1254 }
1255 ids
1256}
1257
1258pub fn route_item_template_candidates(
1263 placed: &[PlacedContainerView],
1264 character_id: Option<uuid::Uuid>,
1265 inventory: &std::collections::HashMap<String, u32>,
1266 blueprints: &[flatland_protocol::BlueprintView],
1267 resource_nodes: &[flatland_protocol::ResourceNodeView],
1268 extra: &[String],
1269) -> Vec<String> {
1270 let mut out = owned_storage_template_ids(placed, character_id);
1271 for (template, qty) in inventory {
1272 if *qty > 0 && !template.is_empty() {
1273 out.push(template.clone());
1274 }
1275 }
1276 for bp in blueprints {
1277 if !bp.output.is_empty() {
1278 out.push(bp.output.clone());
1279 }
1280 for input in &bp.inputs {
1281 if !input.template_id.is_empty() {
1282 out.push(input.template_id.clone());
1283 }
1284 }
1285 for tool in &bp.required_tools {
1286 if !tool.item.is_empty() {
1287 out.push(tool.item.clone());
1288 }
1289 }
1290 }
1291 for node in resource_nodes {
1292 if !node.item_template.is_empty() {
1293 out.push(node.item_template.clone());
1294 }
1295 }
1296 for t in extra {
1297 if !t.is_empty() {
1298 out.push(t.clone());
1299 }
1300 }
1301 out.sort();
1302 out.dedup();
1303 out
1304}
1305
1306pub fn pick_storage_container_at(
1308 placed: &[PlacedContainerView],
1309 character_id: Option<uuid::Uuid>,
1310 x: f32,
1311 y: f32,
1312) -> Option<String> {
1313 let cid = character_id?;
1314 placed
1315 .iter()
1316 .filter(|c| c.owner_character_id == Some(cid))
1317 .filter(|c| c.capacity_volume.unwrap_or(0.0) > 0.0)
1318 .filter_map(|c| {
1319 let d = dist2d(x, y, c.x, c.y);
1320 if d <= STORAGE_PICK_M {
1321 Some((d, c.id.clone()))
1322 } else {
1323 None
1324 }
1325 })
1326 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1327 .map(|(_, id)| id)
1328}
1329
1330#[cfg(test)]
1331mod tests {
1332 use super::*;
1333
1334 #[test]
1335 fn worker_craft_blueprint_ids_filters_to_known_recipes() {
1336 use flatland_protocol::{BlueprintIngredientView, BlueprintView};
1337
1338 fn bp(id: &str) -> BlueprintView {
1339 BlueprintView {
1340 id: id.into(),
1341 label: id.into(),
1342 output: "x".into(),
1343 output_qty: 1,
1344 craft_ticks: 1,
1345 inputs: vec![BlueprintIngredientView {
1346 template_id: "oak_log".into(),
1347 quantity: 1,
1348 consumed: true,
1349 }],
1350 station: Some("hand".into()),
1351 category: None,
1352 required_tools: vec![],
1353 skill: None,
1354 failure_chance: 0.0,
1355 worker_train_copper: 0,
1356 }
1357 }
1358
1359 let all = vec![
1360 bp("oak_to_lumber"),
1361 bp("vegetable_soup"),
1362 bp("craft_simple_camp_bed"),
1363 bp("craft_wooden_chest_small"),
1364 bp("iron_ingot"),
1365 ];
1366 let known = vec![
1367 "oak_to_lumber".into(),
1368 "craft_simple_camp_bed".into(),
1369 "craft_wooden_chest_small".into(),
1370 ];
1371
1372 let filtered = worker_craft_blueprint_ids(&all, Some(&known));
1373 assert_eq!(
1374 filtered,
1375 vec![
1376 "oak_to_lumber",
1377 "craft_simple_camp_bed",
1378 "craft_wooden_chest_small"
1379 ]
1380 );
1381
1382 assert_eq!(
1384 worker_craft_blueprint_ids(&all, Some(&[])).len(),
1385 all.len()
1386 );
1387 assert_eq!(worker_craft_blueprint_ids(&all, None).len(), all.len());
1388 }
1389
1390 #[test]
1391 fn route_item_candidates_include_craft_outputs_not_in_storage() {
1392 use flatland_protocol::{
1393 BlueprintIngredientView, BlueprintView, PlacedContainerView, ResourceNodeView,
1394 ResourceNodeState,
1395 };
1396 use std::collections::HashMap;
1397 use uuid::Uuid;
1398
1399 let cid = Uuid::from_u128(0x1111_2222_3333_4444_5555_6666_7777_8888);
1400 let placed = vec![PlacedContainerView {
1401 id: "chest-1".into(),
1402 template_id: "wooden_chest_small".into(),
1403 display_name: "Chest".into(),
1404 x: 0.0,
1405 y: 0.0,
1406 z: 0.0,
1407 locked: false,
1408 accessible: true,
1409 owner_character_id: Some(cid),
1410 contents: vec![],
1411 lock_id: None,
1412 capacity_volume: Some(20.0),
1413 item_instance_id: None,
1414 tile_id: None,
1415 paperdoll_ref: None,
1416 worker_lodging_capacity: None,
1417 }];
1418 let blueprints = vec![BlueprintView {
1419 id: "smelt_iron".into(),
1420 label: "Smelt Iron".into(),
1421 output: "iron_ingot".into(),
1422 output_qty: 1,
1423 craft_ticks: 30,
1424 inputs: vec![BlueprintIngredientView {
1425 template_id: "iron_ore".into(),
1426 quantity: 1,
1427 consumed: true,
1428 }],
1429 station: Some("hand".into()),
1430 category: None,
1431 required_tools: vec![],
1432 skill: None,
1433 failure_chance: 0.0,
1434 worker_train_copper: 0,
1435 }];
1436 let nodes = vec![ResourceNodeView {
1437 id: "ore-1".into(),
1438 label: "Iron Ore".into(),
1439 x: 1.0,
1440 y: 1.0,
1441 z: 0.0,
1442 item_template: "iron_ore".into(),
1443 state: ResourceNodeState::Available,
1444 blocking: true,
1445 blocking_radius_m: 0.8,
1446 tile_id: None,
1447 paperdoll_ref: None,
1448 yaw: 0.0,
1449 pitch: 0.0,
1450 roll: 0.0,
1451 draw_scale: 1.0,
1452 sprite_mode: None,
1453 presentation_state: None,
1454 }];
1455 let ids = route_item_template_candidates(
1456 &placed,
1457 Some(cid),
1458 &HashMap::new(),
1459 &blueprints,
1460 &nodes,
1461 &[],
1462 );
1463 assert!(
1464 ids.contains(&"iron_ingot".to_string()),
1465 "craft output should be selectable before any exists in storage: {ids:?}"
1466 );
1467 assert!(ids.contains(&"iron_ore".to_string()));
1468 }
1469
1470 #[test]
1471 fn build_ordered_job_yaml_includes_stops() {
1472 let mut ed = WorkerRouteEditorState::new(
1473 "worker-worker_laborer-1".into(),
1474 "Laborer".into(),
1475 Some("chest-bed".into()),
1476 );
1477 ed.append_waypoint(10.0, 20.0, 0.0);
1478 ed.append_harvest_node("oak-n1");
1479 ed.append_deposit_at("chest-storage-a");
1480 ed.append_rest_if_needed();
1481 let yaml = ed.build_job_yaml().expect("yaml");
1482 assert!(yaml.contains("kind: ordered"));
1483 assert!(yaml.contains("lodging_container_id: chest-bed"));
1484 assert!(yaml.contains("stop: waypoint"));
1485 assert!(yaml.contains("oak-n1"));
1486 assert!(yaml.contains("deposit_at"));
1487 assert!(yaml.contains("chest-storage-a"));
1488 assert!(yaml.contains("rest_if_needed"));
1489 }
1490
1491 #[test]
1492 fn requires_at_least_one_stop() {
1493 let ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1494 assert!(ed.build_job_yaml().is_err());
1495 }
1496
1497 #[test]
1498 fn reorder_stops() {
1499 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1500 ed.append_harvest_node("oak-a");
1501 ed.append_harvest_node("oak-b");
1502 ed.append_waypoint(5.0, 6.0, 0.0);
1503 ed.select_stop(1);
1505 ed.move_selected_up();
1506 assert!(
1507 matches!(&ed.stops[0], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
1508 );
1509 ed.move_selected_down();
1511 assert!(
1512 matches!(&ed.stops[1], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
1513 );
1514 }
1515
1516 #[test]
1517 fn duplicate_harvest_node_selects_existing_instead() {
1518 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1519 assert!(ed.append_harvest_node("oak-a"));
1520 ed.append_waypoint(1.0, 2.0, 0.0);
1521 assert!(!ed.append_harvest_node("oak-a"));
1522 assert_eq!(ed.stops.len(), 2);
1523 assert_eq!(ed.selected_stop_index, 0);
1524 }
1525
1526 #[test]
1527 fn duplicate_deposit_container_selects_existing_instead() {
1528 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1529 assert!(ed.append_deposit_at("chest-1"));
1530 ed.append_harvest_node("oak-a");
1531 assert!(!ed.append_deposit_at("chest-1"));
1532 assert_eq!(ed.stops.len(), 2);
1533 assert_eq!(ed.selected_stop_index, 0);
1534 }
1535
1536 #[test]
1537 fn duplicate_trade_stop_selects_existing_instead() {
1538 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1539 assert!(ed.append_trade_with("oak_log".into(), Some("ada".into()), true));
1540 assert!(!ed.append_trade_with("oak_log".into(), Some("ada".into()), true));
1541 assert!(ed.append_trade_with("lumber".into(), Some("ada".into()), true));
1543 assert_eq!(ed.stops.len(), 2);
1544 }
1545
1546 #[test]
1547 fn build_idle_job_yaml_parks_worker() {
1548 let ed = WorkerRouteEditorState::new("w1".into(), "L".into(), Some("bed-1".into()));
1549 let yaml = ed.build_idle_job_yaml();
1550 assert!(yaml.contains("mode: idle"));
1551 assert!(yaml.contains("steps: []"));
1552 assert!(!yaml.contains("route:"));
1553 }
1554
1555 #[test]
1556 fn remove_selected_stop_adjusts_index() {
1557 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1558 ed.append_waypoint(1.0, 2.0, 0.0);
1559 ed.append_harvest_node("oak-a");
1560 ed.append_deposit_at("chest-1");
1561 ed.select_stop(2);
1562 ed.remove_selected_stop();
1563 assert_eq!(ed.stops.len(), 2);
1564 assert_eq!(ed.selected_stop_index, 1);
1565 }
1566
1567 #[test]
1568 fn build_job_yaml_includes_trade_with_stop() {
1569 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1570 ed.append_trade_with("oak_log".into(), None, true);
1571 ed.append_trade_with("lumber".into(), Some("ada_broker".into()), false);
1572 let yaml = ed.build_job_yaml().expect("yaml");
1573 assert!(yaml.contains("stop: trade_with, template: oak_log, sell_all: true"));
1574 assert!(yaml.contains("npc_id: ada_broker"));
1575 assert!(yaml.contains("sell_all: false"));
1576 }
1577
1578 #[test]
1579 fn set_selected_trade_npc_updates_stop() {
1580 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1581 ed.append_trade_with("oak_log".into(), None, true);
1582 assert!(ed.set_selected_trade_npc("ada_broker".into()));
1583 assert!(matches!(&ed.stops[0], WorkerRouteStop::TradeWith { npc_id, .. } if npc_id.as_deref() == Some("ada_broker")));
1584 }
1585
1586 #[test]
1587 fn build_job_yaml_deposit_filter_round_trips() {
1588 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), Some("bed-1".into()));
1589 ed.append_deposit_at_filtered("chest-out", vec!["lumber".into()]);
1590 let yaml = ed.build_job_yaml().expect("yaml");
1591 assert!(yaml.contains("stop: deposit_at, container_id: chest-out, filter: [lumber]"));
1592 }
1593
1594 #[test]
1595 fn build_job_yaml_includes_withdraw_and_craft_stops() {
1596 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1597 ed.stops.push(WorkerRouteStop::WithdrawFrom {
1598 container_id: "chest-src".into(),
1599 items: vec![WorkerRouteWithdrawItem { template: "oak_log".into(), qty: None }],
1600 });
1601 ed.stops.push(WorkerRouteStop::WithdrawFrom {
1602 container_id: "chest-src-2".into(),
1603 items: vec![WorkerRouteWithdrawItem { template: "iron_ore".into(), qty: Some(10) }],
1604 });
1605 ed.stops.push(WorkerRouteStop::CraftAt {
1606 device: "hand".into(),
1607 blueprint: "oak_to_lumber".into(),
1608 qty: None,
1609 });
1610 ed.append_deposit_at("chest-out");
1611 let yaml = ed.build_job_yaml().expect("yaml");
1612 assert!(yaml.contains("stop: withdraw_from"));
1613 assert!(yaml.contains("container_id: chest-src"));
1614 assert!(yaml.contains("template: oak_log, all: true"));
1615 assert!(yaml.contains("template: iron_ore, qty: 10"));
1616 assert!(yaml.contains("stop: craft_at, device: hand, blueprint: oak_to_lumber"));
1617 assert!(yaml.contains("stop: deposit_at"));
1618 }
1619
1620 #[test]
1621 fn withdraw_summary_shows_all_vs_qty() {
1622 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1623 ed.stops.push(WorkerRouteStop::WithdrawFrom {
1624 container_id: "chest-src".into(),
1625 items: vec![WorkerRouteWithdrawItem { template: "oak_log".into(), qty: None }],
1626 });
1627 assert!(ed.stops[0].summary().contains("withdraw all oak_log"));
1628 }
1629
1630 #[test]
1631 fn withdraw_view_round_trips_all_flag() {
1632 let view = WorkerRouteStopView::WithdrawFrom {
1633 container_id: "chest-1".into(),
1634 items: vec![
1635 flatland_protocol::WorkerWithdrawItemView {
1636 template: "oak_log".into(),
1637 qty: 0,
1638 all: true,
1639 },
1640 flatland_protocol::WorkerWithdrawItemView {
1641 template: "iron_ore".into(),
1642 qty: 5,
1643 all: false,
1644 },
1645 ],
1646 };
1647 let stop = stop_view_to_stop(&view);
1648 let WorkerRouteStop::WithdrawFrom { items, .. } = stop else {
1649 panic!("expected withdraw stop");
1650 };
1651 assert_eq!(items[0].qty, None);
1652 assert_eq!(items[1].qty, Some(5));
1653 }
1654
1655 #[test]
1656 fn legacy_harvest_loop_route_converts_to_ordered_stops() {
1657 let route = WorkerRouteView {
1658 kind: WorkerRouteKindView::HarvestLoop,
1659 lodging_container_id: Some("bed-1".into()),
1660 outbound_waypoints: vec![flatland_protocol::WorkerRouteWaypointView {
1661 x: 1.0,
1662 y: 2.0,
1663 z: 0.0,
1664 }],
1665 harvest_nodes: vec!["oak-1".into()],
1666 carry_return_ratio: 0.9,
1667 stops: Vec::new(),
1668 };
1669 let ed = WorkerRouteEditorState::from_saved_route(
1670 "w1".into(),
1671 "L".into(),
1672 &route,
1673 None,
1674 );
1675 assert_eq!(ed.stops.len(), 4);
1677 assert!(matches!(&ed.stops[2], WorkerRouteStop::DepositAt { container_id, .. } if container_id == "bed-1"));
1678 assert!(matches!(&ed.stops[3], WorkerRouteStop::RestIfNeeded));
1679 }
1680
1681 #[test]
1684 fn retarget_withdraw_container_updates_editing_stop() {
1685 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1686 ed.stops.push(WorkerRouteStop::WithdrawFrom {
1687 container_id: "chest-old".into(),
1688 items: vec![WorkerRouteWithdrawItem {
1689 template: "iron_ore".into(),
1690 qty: None,
1691 }],
1692 });
1693 ed.selected_stop_index = 0;
1694 ed.editing_index = Some(0);
1695 assert!(ed.retarget_withdraw_container("chest-new".into()));
1696 assert!(matches!(
1697 &ed.stops[0],
1698 WorkerRouteStop::WithdrawFrom { container_id, .. } if container_id == "chest-new"
1699 ));
1700 }
1701
1702 #[test]
1703 fn summary_resolved_uses_friendly_labels() {
1704 let stop = WorkerRouteStop::WithdrawFrom {
1705 container_id: "uuid-iron".into(),
1706 items: vec![WorkerRouteWithdrawItem {
1707 template: "iron_ore".into(),
1708 qty: None,
1709 }],
1710 };
1711 let summary = stop.summary_resolved(
1712 |_| "Iron Ore Container".into(),
1713 |_| "Ada".into(),
1714 |_| "Oak Tree".into(),
1715 );
1716 assert_eq!(summary, "withdraw all iron_ore from Iron Ore Container");
1717 assert!(!summary.contains("uuid"));
1718 }
1719
1720 #[test]
1721 fn sheet_back_walks_up_hierarchy() {
1722 use RouteEditorSheet as S;
1723 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1724 assert_eq!(ed.sheet, S::Stops);
1725 ed.open_add_menu();
1726 assert_eq!(ed.sheet, S::AddMenu { index: 0 });
1727 ed.open_sheet(S::WithdrawContainers { index: 0 });
1728 ed.open_sheet(S::WithdrawItems {
1729 container_id: "c1".into(),
1730 lines: vec![],
1731 index: 0,
1732 });
1733 ed.sheet_back();
1734 assert_eq!(ed.sheet, S::WithdrawContainers { index: 0 });
1735 ed.sheet_back();
1736 assert_eq!(ed.sheet, S::AddMenu { index: 0 });
1737 ed.sheet_back();
1738 assert_eq!(ed.sheet, S::Stops);
1739 ed.sheet_back();
1741 assert_eq!(ed.sheet, S::Stops);
1742 }
1743
1744 #[test]
1745 fn sheet_back_while_editing_returns_to_stops() {
1746 use RouteEditorSheet as S;
1747 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1748 ed.append_harvest_node("oak-a");
1749 ed.begin_edit_selected();
1750 ed.open_sheet(S::HarvestPicker { index: 0 });
1751 ed.sheet_back();
1752 assert_eq!(ed.sheet, S::Stops);
1753 assert_eq!(ed.editing_index, None);
1754 }
1755
1756 #[test]
1757 fn sheet_back_while_editing_withdraw_keeps_edit_on_container_picker() {
1758 use RouteEditorSheet as S;
1759 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1760 ed.stops.push(WorkerRouteStop::WithdrawFrom {
1761 container_id: "chest-a".into(),
1762 items: vec![WorkerRouteWithdrawItem {
1763 template: "oak_log".into(),
1764 qty: None,
1765 }],
1766 });
1767 ed.begin_edit_selected();
1768 ed.open_sheet(S::WithdrawItems {
1769 container_id: "chest-a".into(),
1770 lines: vec![],
1771 index: 0,
1772 });
1773 ed.sheet_back();
1774 assert!(matches!(ed.sheet, S::WithdrawContainers { .. }));
1775 assert_eq!(ed.editing_index, Some(0), "still editing after back to picker");
1776 ed.sheet_back();
1777 assert_eq!(ed.sheet, S::Stops);
1778 assert_eq!(ed.editing_index, None);
1779 }
1780
1781 #[test]
1782 fn confirm_stop_replaces_withdraw_container_when_editing() {
1783 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1784 ed.stops.push(WorkerRouteStop::WithdrawFrom {
1785 container_id: "chest-old".into(),
1786 items: vec![WorkerRouteWithdrawItem {
1787 template: "oak_log".into(),
1788 qty: None,
1789 }],
1790 });
1791 ed.stops.push(WorkerRouteStop::RestIfNeeded);
1792 ed.select_stop(0);
1793 ed.begin_edit_selected();
1794 assert!(ed.confirm_stop(WorkerRouteStop::WithdrawFrom {
1795 container_id: "chest-new".into(),
1796 items: vec![WorkerRouteWithdrawItem {
1797 template: "oak_log".into(),
1798 qty: None,
1799 }],
1800 }));
1801 assert_eq!(ed.stops.len(), 2);
1802 assert!(matches!(
1803 &ed.stops[0],
1804 WorkerRouteStop::WithdrawFrom { container_id, .. } if container_id == "chest-new"
1805 ));
1806 }
1807
1808 #[test]
1809 fn confirm_stop_replaces_when_editing() {
1810 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1811 ed.append_harvest_node("oak-a");
1812 ed.append_waypoint(1.0, 1.0, 0.0);
1813 ed.select_stop(0);
1814 ed.begin_edit_selected();
1815 assert!(ed.confirm_stop(WorkerRouteStop::HarvestNode {
1816 node_id: "oak-b".into()
1817 }));
1818 assert_eq!(ed.stops.len(), 2, "edit replaces in place, no append");
1819 assert!(
1820 matches!(&ed.stops[0], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
1821 );
1822 assert_eq!(ed.sheet, RouteEditorSheet::Stops);
1823 assert_eq!(ed.editing_index, None);
1824 }
1825
1826 #[test]
1827 fn confirm_stop_dedupes_on_append() {
1828 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1829 ed.append_harvest_node("oak-a");
1830 assert!(!ed.confirm_stop(WorkerRouteStop::HarvestNode {
1831 node_id: "oak-a".into()
1832 }));
1833 assert_eq!(ed.stops.len(), 1);
1834 assert_eq!(ed.selected_stop_index, 0);
1835 }
1836
1837 #[test]
1838 fn withdraw_line_cycle_and_collect() {
1839 let contents = vec![
1840 ItemStack {
1841 template_id: "oak_log".into(),
1842 quantity: 12,
1843 ..Default::default()
1844 },
1845 ItemStack {
1846 template_id: "lumber".into(),
1847 quantity: 4,
1848 ..Default::default()
1849 },
1850 ];
1851 let mut lines =
1852 WorkerRouteEditorState::withdraw_line_drafts(&contents, &[]);
1853 assert_eq!(lines.len(), 2);
1854 lines[1].cycle();
1856 assert_eq!(lines[1].mode, WithdrawLineMode::All);
1857 lines[0].cycle();
1858 lines[0].cycle();
1859 assert!(matches!(lines[0].mode, WithdrawLineMode::Qty(_)));
1860 lines[0].adjust_qty(5);
1861 let items = WorkerRouteEditorState::withdraw_items_from_lines(&lines);
1862 assert_eq!(items.len(), 2);
1863 assert_eq!(items[0].template, "lumber");
1864 assert_eq!(items[0].qty, Some(4));
1866 assert_eq!(items[1].qty, None);
1867 }
1868
1869 #[test]
1870 fn withdraw_drafts_prefill_existing_and_keep_missing() {
1871 let contents = vec![ItemStack {
1872 template_id: "oak_log".into(),
1873 quantity: 3,
1874 ..Default::default()
1875 }];
1876 let existing = vec![
1877 WorkerRouteWithdrawItem { template: "oak_log".into(), qty: None },
1878 WorkerRouteWithdrawItem { template: "iron_ore".into(), qty: Some(5) },
1879 ];
1880 let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
1881 assert_eq!(lines.len(), 2);
1882 let ore = lines.iter().find(|l| l.template == "iron_ore").expect("ore line");
1883 assert_eq!(ore.available, 0, "missing template kept with 0 available");
1884 assert_eq!(ore.mode, WithdrawLineMode::Qty(5));
1885 let oak = lines.iter().find(|l| l.template == "oak_log").expect("oak line");
1886 assert_eq!(oak.mode, WithdrawLineMode::All);
1887 }
1888}