1use std::collections::BTreeSet;
16
17use flatland_protocol::{
18 ItemCatalogEntryView, ItemStack, NpcView, PlacedContainerView, ResourceNodeView,
19 WorkerRouteKindView, WorkerRouteStopView, WorkerRouteView,
20};
21
22#[derive(Debug, Clone, PartialEq)]
24pub struct WorkerRouteWaypoint {
25 pub x: f32,
26 pub y: f32,
27 pub z: f32,
28}
29
30#[derive(Debug, Clone, PartialEq)]
32pub enum WorkerRouteStop {
33 Waypoint {
34 x: f32,
35 y: f32,
36 z: f32,
37 },
38 HarvestNode {
39 node_id: String,
40 },
41 DepositAt {
42 container_id: String,
43 filter: Option<Vec<String>>,
45 },
46 TradeWith {
49 npc_id: Option<String>,
50 template: String,
51 sell_all: bool,
52 },
53 ListOnMarket {
55 template: String,
56 list_all: bool,
57 hall_id: Option<String>,
58 },
59 WithdrawFrom {
61 container_id: String,
62 items: Vec<WorkerRouteWithdrawItem>,
63 },
64 CraftAt {
66 device: String,
67 blueprint: String,
68 qty: Option<u32>,
69 },
70 CultivatePlot {
71 plot_id: uuid::Uuid,
72 },
73 PlantPlot {
74 plot_id: uuid::Uuid,
75 seed_template: String,
76 },
77 HarvestPlot {
78 plot_id: uuid::Uuid,
79 },
80 RestIfNeeded,
81 Wait {
82 wait_ticks: u64,
83 },
84}
85
86#[derive(Debug, Clone, PartialEq)]
88pub struct WorkerRouteWithdrawItem {
89 pub template: String,
90 pub qty: Option<u32>,
93}
94
95impl WorkerRouteStop {
96 pub fn summary(&self) -> String {
97 self.summary_resolved(
98 |id| short_id(id),
99 |id| id.to_string(),
100 |id| id.to_string(),
101 |id| format!("plot {}", short_plot_id(id)),
102 |id| id.to_string(),
103 )
104 }
105
106 pub fn summary_resolved(
108 &self,
109 container_label: impl Fn(&str) -> String,
110 npc_label: impl Fn(&str) -> String,
111 node_label: impl Fn(&str) -> String,
112 plot_label: impl Fn(&uuid::Uuid) -> String,
113 item_label: impl Fn(&str) -> String,
114 ) -> String {
115 match self {
116 Self::Waypoint { x, y, .. } => format!("waypoint ({x:.0}, {y:.0})"),
117 Self::HarvestNode { node_id } => format!("harvest {}", node_label(node_id)),
118 Self::DepositAt {
119 container_id,
120 filter,
121 } => {
122 let f = filter
123 .as_ref()
124 .filter(|ids| !ids.is_empty())
125 .map(|ids| {
126 let names = ids
127 .iter()
128 .map(|t| item_label(t))
129 .collect::<Vec<_>>()
130 .join(", ");
131 format!(" only {names}")
132 })
133 .unwrap_or_default();
134 format!("deposit at {}{f}", container_label(container_id))
135 }
136 Self::TradeWith {
137 npc_id, template, ..
138 } => {
139 let who = npc_id
140 .as_deref()
141 .map(|id| npc_label(id))
142 .unwrap_or_else(|| "nearest buyer".into());
143 format!("sell {} to {who}", item_label(template))
144 }
145 Self::ListOnMarket {
146 template, hall_id, ..
147 } => {
148 let hall = hall_id.as_deref().unwrap_or("market hall");
149 format!("list {} on {hall} (NPC price)", item_label(template))
150 }
151 Self::WithdrawFrom {
152 container_id,
153 items,
154 } => {
155 let what = items
156 .iter()
157 .map(|i| match i.qty {
158 None => format!("all {}", item_label(&i.template)),
159 Some(q) => format!("up to {q} {}", item_label(&i.template)),
160 })
161 .collect::<Vec<_>>()
162 .join(" + ");
163 format!("withdraw {what} from {}", container_label(container_id))
164 }
165 Self::CraftAt { blueprint, .. } => format!("craft {blueprint}"),
166 Self::CultivatePlot { plot_id } => {
167 format!("cultivate {}", plot_label(plot_id))
168 }
169 Self::PlantPlot {
170 plot_id,
171 seed_template,
172 } => format!("plant {} on {}", item_label(seed_template), plot_label(plot_id)),
173 Self::HarvestPlot { plot_id } => {
174 format!("harvest {}", plot_label(plot_id))
175 }
176 Self::RestIfNeeded => "rest at lodging (if needed)".into(),
177 Self::Wait { wait_ticks } => format!("wait {wait_ticks}t"),
178 }
179 }
180
181 pub fn kind_label(&self) -> &'static str {
183 match self {
184 Self::Waypoint { .. } => "waypoint",
185 Self::HarvestNode { .. } => "harvest",
186 Self::DepositAt { .. } => "deposit",
187 Self::TradeWith { .. } => "sell",
188 Self::ListOnMarket { .. } => "market-list",
189 Self::WithdrawFrom { .. } => "withdraw",
190 Self::CraftAt { .. } => "craft",
191 Self::CultivatePlot { .. } => "cultivate",
192 Self::PlantPlot { .. } => "plant",
193 Self::HarvestPlot { .. } => "harvest-plot",
194 Self::RestIfNeeded => "rest",
195 Self::Wait { .. } => "wait",
196 }
197 }
198}
199
200fn short_id(id: &str) -> String {
201 id.rsplit('-')
202 .next()
203 .filter(|s| !s.is_empty())
204 .unwrap_or(id)
205 .to_string()
206}
207
208fn short_plot_id(plot_id: &uuid::Uuid) -> String {
209 let s = plot_id.to_string();
210 s.get(..8).unwrap_or(s.as_str()).to_string()
211}
212
213pub fn list_filter_row_matches(filter: &str, dist_m: Option<f32>, fields: &[&str]) -> bool {
218 let tokens: Vec<&str> = filter
219 .split_whitespace()
220 .filter(|t| !t.is_empty())
221 .collect();
222 if tokens.is_empty() {
223 return true;
224 }
225 let hay: Vec<String> = fields.iter().map(|f| f.to_ascii_lowercase()).collect();
226 for tok in tokens {
227 let t = tok.to_ascii_lowercase();
228 if let Some(rest) = t.strip_suffix('m') {
229 if let Ok(max) = rest.parse::<f32>() {
230 if let Some(d) = dist_m {
231 if d > max {
232 return false;
233 }
234 continue;
235 }
236 }
237 }
238 if !hay.iter().any(|h| h.contains(&t)) {
239 return false;
240 }
241 }
242 true
243}
244
245#[derive(Debug, Clone, Copy, PartialEq)]
249pub enum WithdrawLineMode {
250 Off,
251 All,
252 Qty(u32),
253}
254
255#[derive(Debug, Clone, PartialEq)]
257pub struct WithdrawLineDraft {
258 pub template: String,
259 pub available: u32,
261 pub mode: WithdrawLineMode,
262}
263
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266pub enum FarmPlotAction {
267 Cultivate,
268 Plant,
269 Harvest,
270}
271
272impl WithdrawLineDraft {
273 pub fn cycle(&mut self) {
275 self.mode = match self.mode {
276 WithdrawLineMode::Off => WithdrawLineMode::All,
277 WithdrawLineMode::All => WithdrawLineMode::Qty(self.available.clamp(1, 10)),
278 WithdrawLineMode::Qty(_) => WithdrawLineMode::Off,
279 };
280 }
281
282 pub fn adjust_qty(&mut self, delta: i32) {
284 let cur = match self.mode {
285 WithdrawLineMode::Off => self.available.clamp(1, 10),
286 WithdrawLineMode::All => self.available.clamp(1, 10),
287 WithdrawLineMode::Qty(q) => q,
288 };
289 let next = (cur as i32 + delta).clamp(1, self.available.max(1) as i32) as u32;
290 self.mode = WithdrawLineMode::Qty(next);
291 }
292}
293
294#[derive(Debug, Clone, PartialEq)]
296pub enum RouteEditorSheet {
297 Stops,
299 AddMenu { index: usize },
301 WaypointMenu { index: usize },
303 WaypointMapPick,
305 HarvestPicker {
308 index: usize,
309 picked: BTreeSet<String>,
310 nodes: Vec<NodeCandidate>,
311 },
312 WithdrawContainers { index: usize },
314 WithdrawItems {
316 container_id: String,
317 lines: Vec<WithdrawLineDraft>,
318 index: usize,
319 },
320 DepositContainers { index: usize },
322 DepositFilter {
324 container_id: String,
325 rows: Vec<(String, bool)>,
327 index: usize,
328 },
329 SellNpcs { index: usize },
331 SellItem {
333 npc_id: Option<String>,
334 templates: Vec<String>,
335 index: usize,
336 sell_all: bool,
337 picked: BTreeSet<String>,
338 },
339 MarketListItem {
341 hall_id: Option<String>,
342 templates: Vec<String>,
343 index: usize,
344 list_all: bool,
345 picked: BTreeSet<String>,
346 },
347 CraftBlueprint { index: usize },
349 WaitEntry { ticks: u64 },
351 BedPicker { index: usize },
353 FarmPlotPicker {
355 index: usize,
356 action: FarmPlotAction,
357 },
358 FarmPlantSeed {
360 plot_id: uuid::Uuid,
361 seeds: Vec<String>,
362 index: usize,
363 },
364}
365
366#[derive(Debug, Clone, Copy, PartialEq)]
368pub enum RouteEditorClick {
369 SelectStop(usize),
371 OpenBedPicker,
373 SheetRow(usize),
375 TogglePanel,
377}
378
379pub const ADD_MENU: &[&str] = &[
381 "Waypoint",
382 "Harvest node",
383 "Withdraw from storage",
384 "Deposit to storage",
385 "Sell to merchant",
386 "List on market (NPC price)",
387 "Craft (at hand)",
388 "Rest if needed",
389 "Wait",
390 "Cultivate plot",
391 "Plant plot",
392 "Harvest plot",
393];
394
395pub const WAYPOINT_MENU: &[&str] = &["At player position", "Pick on map (click)"];
397
398#[derive(Debug, Clone, PartialEq)]
402pub struct ContainerCandidate {
403 pub id: String,
404 pub name: String,
405 pub is_lodging: bool,
406 pub summary: String,
408 pub dist: f32,
409}
410
411pub fn summarize_contents(contents: &[ItemStack]) -> String {
413 let mut totals: Vec<(String, u32)> = Vec::new();
414 for s in contents {
415 if s.template_id.is_empty() {
416 continue;
417 }
418 match totals.iter_mut().find(|(t, _)| t == &s.template_id) {
419 Some((_, q)) => *q += s.quantity,
420 None => totals.push((s.template_id.clone(), s.quantity)),
421 }
422 }
423 if totals.is_empty() {
424 return "(empty)".into();
425 }
426 totals.sort();
427 totals
428 .iter()
429 .map(|(t, q)| format!("{t} ×{q}"))
430 .collect::<Vec<_>>()
431 .join(" · ")
432}
433
434pub fn owned_container_candidates(
437 placed: &[PlacedContainerView],
438 character_id: Option<uuid::Uuid>,
439 px: f32,
440 py: f32,
441) -> Vec<ContainerCandidate> {
442 owned_container_candidates_with_occupants(placed, character_id, px, py, &[])
443}
444
445pub fn owned_container_candidates_with_occupants(
447 placed: &[PlacedContainerView],
448 character_id: Option<uuid::Uuid>,
449 px: f32,
450 py: f32,
451 hired: &[flatland_protocol::HiredWorkerView],
452) -> Vec<ContainerCandidate> {
453 owned_container_candidates_with_occupants_and_buildings(
454 placed,
455 &[],
456 character_id,
457 px,
458 py,
459 hired,
460 None,
461 )
462}
463
464pub fn owned_container_candidates_with_occupants_and_buildings(
466 placed: &[PlacedContainerView],
467 buildings: &[flatland_protocol::BuildingView],
468 character_id: Option<uuid::Uuid>,
469 px: f32,
470 py: f32,
471 hired: &[flatland_protocol::HiredWorkerView],
472 observer_inside: Option<&str>,
473) -> Vec<ContainerCandidate> {
474 let Some(cid) = character_id else {
475 return Vec::new();
476 };
477 let mut out: Vec<ContainerCandidate> = placed
478 .iter()
479 .filter(|c| c.owner_character_id == Some(cid))
480 .filter(|c| {
481 c.capacity_volume.unwrap_or(0.0) > 0.0 || c.worker_lodging_capacity.unwrap_or(0) > 0
482 })
483 .map(|c| {
484 let is_lodging = c.worker_lodging_capacity.unwrap_or(0) > 0;
485 let mut summary = summarize_contents(&c.contents);
486 if is_lodging {
487 let who = lodging_occupants_for(hired, &c.id);
488 let who = if who.is_empty() {
489 "vacant".into()
490 } else {
491 who.join(", ")
492 };
493 summary = format!("lodged: {who} · {summary}");
494 }
495 let cross_space = !container_in_observer_space(c, observer_inside);
496 let building = c
497 .building_id
498 .as_ref()
499 .and_then(|bid| buildings.iter().find(|b| &b.id == bid));
500 let (rx, ry) = if cross_space {
503 building
504 .map(|b| (b.x + b.width_m * 0.5, b.y + b.depth_m * 0.5))
505 .unwrap_or((c.x, c.y))
506 } else {
507 (c.x, c.y)
508 };
509 let name = if cross_space {
510 match building {
511 Some(b) if !b.label.is_empty() => {
512 format!("{} ({})", c.display_name, b.label)
513 }
514 Some(b) => format!("{} ({})", c.display_name, b.id),
515 None => c.display_name.clone(),
516 }
517 } else {
518 c.display_name.clone()
519 };
520 ContainerCandidate {
521 id: c.id.clone(),
522 name,
523 is_lodging,
524 summary,
525 dist: dist2d(px, py, rx, ry),
526 }
527 })
528 .collect();
529
530 for b in buildings {
531 if !b.tags.iter().any(|t| t.eq_ignore_ascii_case("storage")) {
532 continue;
533 }
534 let name = if b.label.is_empty() {
535 format!("Town storage ({})", b.id)
536 } else {
537 b.label.clone()
538 };
539 out.push(ContainerCandidate {
540 id: b.id.clone(),
541 name,
542 is_lodging: false,
543 summary: "(town vault)".into(),
544 dist: dist2d(px, py, b.x, b.y),
545 });
546 }
547
548 out.sort_by(|a, b| {
549 a.dist
550 .partial_cmp(&b.dist)
551 .unwrap_or(std::cmp::Ordering::Equal)
552 .then_with(|| a.name.cmp(&b.name))
553 .then_with(|| a.id.cmp(&b.id))
554 });
555 out
556}
557
558fn lodging_occupants_for(
559 hired: &[flatland_protocol::HiredWorkerView],
560 container_id: &str,
561) -> Vec<String> {
562 let mut names: Vec<String> = hired
563 .iter()
564 .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
565 .map(|w| w.label.clone())
566 .collect();
567 names.sort();
568 names
569}
570
571pub fn is_harvest_route_node(n: &ResourceNodeView) -> bool {
573 !n.harvest_off
574 && !n.id.starts_with("preview:")
575 && !n.id.starts_with("carcass-")
576 && !n.id.contains("::")
577}
578
579fn node_candidate_label(n: &ResourceNodeView) -> String {
580 let base = crate::resource_node_route_label(n);
581 match n.state {
582 flatland_protocol::ResourceNodeState::Cooldown => format!("{base} (depleted)"),
583 flatland_protocol::ResourceNodeState::Harvesting => format!("{base} (harvesting)"),
584 flatland_protocol::ResourceNodeState::Available => base,
585 }
586}
587
588#[derive(Debug, Clone, PartialEq)]
590pub struct NodeCandidate {
591 pub id: String,
592 pub label: String,
593 pub template: String,
594 pub dist: f32,
595}
596
597pub fn node_candidates(
601 nodes: &[ResourceNodeView],
602 anchor_x: f32,
603 anchor_y: f32,
604) -> Vec<NodeCandidate> {
605 let mut out: Vec<NodeCandidate> = nodes
606 .iter()
607 .filter(|n| is_harvest_route_node(n))
608 .map(|n| NodeCandidate {
609 id: n.id.clone(),
610 label: node_candidate_label(n),
611 template: n.item_template.clone(),
612 dist: dist2d(anchor_x, anchor_y, n.x, n.y),
613 })
614 .collect();
615 out.sort_by(|a, b| {
616 a.dist
617 .partial_cmp(&b.dist)
618 .unwrap_or(std::cmp::Ordering::Equal)
619 .then_with(|| a.label.cmp(&b.label))
620 .then_with(|| a.id.cmp(&b.id))
621 });
622 out
623}
624
625pub fn node_candidates_stable(nodes: &[ResourceNodeView]) -> Vec<NodeCandidate> {
627 let mut out: Vec<NodeCandidate> = nodes
628 .iter()
629 .filter(|n| is_harvest_route_node(n))
630 .map(|n| NodeCandidate {
631 id: n.id.clone(),
632 label: node_candidate_label(n),
633 template: n.item_template.clone(),
634 dist: f32::NAN,
635 })
636 .collect();
637 out.sort_by(|a, b| a.label.cmp(&b.label).then_with(|| a.id.cmp(&b.id)));
638 out
639}
640
641pub fn route_editor_lodging_anchor(
643 lodging_container_id: Option<&str>,
644 placed: &[PlacedContainerView],
645) -> Option<(f32, f32)> {
646 let id = lodging_container_id?;
647 placed.iter().find(|c| c.id == id).map(|c| (c.x, c.y))
648}
649
650pub const ROUTE_PICKER_DONE_ROW: usize = 0;
652pub const SELL_ITEM_TOGGLE_ROW: usize = 1;
654
655pub fn harvest_picker_row_count(nodes_len: usize) -> usize {
656 nodes_len + 1
657}
658
659pub fn sell_item_picker_row_count(templates_len: usize) -> usize {
660 templates_len + 2
661}
662
663pub fn harvest_picker_row_matches(nodes: &[NodeCandidate], row: usize, filter: &str) -> bool {
665 if row == ROUTE_PICKER_DONE_ROW {
666 return true;
667 }
668 let slot = row - 1;
669 nodes.get(slot).is_some_and(|n| {
670 let dist = n.dist.is_finite().then_some(n.dist);
671 list_filter_row_matches(filter, dist, &[&n.label, &n.template, &n.id])
672 })
673}
674
675#[derive(Debug, Clone, PartialEq)]
677pub struct TradeNpcCandidate {
678 pub id: String,
679 pub label: String,
680 pub dist: f32,
681 pub buys_route_item: bool,
683}
684
685pub fn merchant_buys_any_route_item(npc: &NpcView, route_templates: &[String]) -> bool {
687 if route_templates.is_empty() {
688 return false;
689 }
690 route_templates
691 .iter()
692 .any(|t| npc.buy_templates.iter().any(|b| b == t))
693}
694
695pub fn any_trade_npc_buys_route_item(npcs: &[NpcView], route_templates: &[String]) -> bool {
697 npcs.iter()
698 .filter(|n| n.can_trade)
699 .any(|n| merchant_buys_any_route_item(n, route_templates))
700}
701
702pub fn sell_merchant_empty_reason(
704 npc_id: Option<&str>,
705 npcs: &[NpcView],
706 route_templates: &[String],
707) -> String {
708 let items = if route_templates.is_empty() {
709 "your route items".to_string()
710 } else {
711 route_templates.join("/")
712 };
713 match npc_id {
714 Some(id) => {
715 let Some(npc) = npcs.iter().find(|n| n.id == id) else {
716 return format!("Route: merchant {id} not found");
717 };
718 let name = if npc.label.is_empty() {
719 npc.id.as_str()
720 } else {
721 npc.label.as_str()
722 };
723 if npc.buy_templates.is_empty() {
724 format!("Route: {name} has an empty buy list — they don't buy any items")
725 } else {
726 format!("Route: {name} doesn't buy any of your route items ({items})")
727 }
728 }
729 None => {
730 if !any_trade_npc_buys_route_item(npcs, route_templates) {
731 format!("Route: no trade NPC buys any of your route items ({items})")
732 } else {
733 format!("Route: no sellable item templates for nearest buyer ({items})")
734 }
735 }
736 }
737}
738
739pub fn trade_npc_candidates(
744 npcs: &[NpcView],
745 px: f32,
746 py: f32,
747 route_templates: &[String],
748) -> Vec<TradeNpcCandidate> {
749 let mut out: Vec<TradeNpcCandidate> = npcs
750 .iter()
751 .filter(|n| n.can_trade)
752 .map(|n| TradeNpcCandidate {
753 id: n.id.clone(),
754 label: n.label.clone(),
755 dist: dist2d(px, py, n.x, n.y),
756 buys_route_item: merchant_buys_any_route_item(n, route_templates),
757 })
758 .collect();
759 out.sort_by(|a, b| {
760 b.buys_route_item
762 .cmp(&a.buys_route_item)
763 .then_with(|| {
764 a.dist
765 .partial_cmp(&b.dist)
766 .unwrap_or(std::cmp::Ordering::Equal)
767 })
768 .then_with(|| a.id.cmp(&b.id))
769 });
770 out
771}
772
773#[derive(Debug, Clone)]
778pub struct WorkerRouteEditorState {
779 pub worker_instance_id: String,
780 pub worker_label: String,
781 pub lodging_container_id: Option<String>,
782 pub stops: Vec<WorkerRouteStop>,
784 pub selected_stop_index: usize,
786 pub carry_return_ratio: f32,
788 pub sheet: RouteEditorSheet,
790 pub editing_index: Option<usize>,
793 pub panel_collapsed: bool,
795 pub sheet_filter: String,
797 pub sheet_filter_focused: bool,
798}
799
800impl WorkerRouteEditorState {
801 pub fn new(
802 worker_instance_id: String,
803 worker_label: String,
804 lodging_container_id: Option<String>,
805 ) -> Self {
806 Self {
807 worker_instance_id,
808 worker_label,
809 lodging_container_id,
810 stops: Vec::new(),
811 selected_stop_index: 0,
812 carry_return_ratio: 0.90,
813 sheet: RouteEditorSheet::Stops,
814 editing_index: None,
815 panel_collapsed: false,
816 sheet_filter: String::new(),
817 sheet_filter_focused: false,
818 }
819 }
820
821 pub fn toggle_panel_collapsed(&mut self) {
822 self.panel_collapsed = !self.panel_collapsed;
823 }
824
825 pub fn from_saved_route(
826 worker_instance_id: String,
827 worker_label: String,
828 route: &WorkerRouteView,
829 lodging_fallback: Option<String>,
830 ) -> Self {
831 let lodging = route.lodging_container_id.clone().or(lodging_fallback);
832
833 match route.kind {
834 WorkerRouteKindView::Ordered => Self {
835 worker_instance_id,
836 worker_label,
837 lodging_container_id: lodging,
838 stops: route.stops.iter().map(stop_view_to_stop).collect(),
839 selected_stop_index: 0,
840 carry_return_ratio: route.carry_return_ratio,
841 sheet: RouteEditorSheet::Stops,
842 editing_index: None,
843 panel_collapsed: false,
844 sheet_filter: String::new(),
845 sheet_filter_focused: false,
846 },
847 WorkerRouteKindView::HarvestLoop => {
848 let mut stops = Vec::new();
853 for wp in &route.outbound_waypoints {
854 stops.push(WorkerRouteStop::Waypoint {
855 x: wp.x,
856 y: wp.y,
857 z: wp.z,
858 });
859 }
860 for node in &route.harvest_nodes {
861 stops.push(WorkerRouteStop::HarvestNode {
862 node_id: node.clone(),
863 });
864 }
865 if let Some(lodging) = &lodging {
866 stops.push(WorkerRouteStop::DepositAt {
867 container_id: lodging.clone(),
868 filter: None,
869 });
870 stops.push(WorkerRouteStop::RestIfNeeded);
871 }
872 Self {
873 worker_instance_id,
874 worker_label,
875 lodging_container_id: lodging,
876 stops,
877 selected_stop_index: 0,
878 carry_return_ratio: route.carry_return_ratio,
879 sheet: RouteEditorSheet::Stops,
880 editing_index: None,
881 panel_collapsed: false,
882 sheet_filter: String::new(),
883 sheet_filter_focused: false,
884 }
885 }
886 }
887 }
888
889 pub fn stop_count(&self) -> usize {
892 self.stops.len()
893 }
894
895 pub fn select_stop(&mut self, index: usize) {
896 if self.stops.is_empty() {
897 self.selected_stop_index = 0;
898 return;
899 }
900 self.selected_stop_index = index.min(self.stops.len() - 1);
901 }
902
903 pub fn move_selected_up(&mut self) {
904 if self.selected_stop_index == 0 {
905 return;
906 }
907 self.stops
908 .swap(self.selected_stop_index, self.selected_stop_index - 1);
909 self.selected_stop_index -= 1;
910 }
911
912 pub fn move_selected_down(&mut self) {
913 if self.selected_stop_index + 1 >= self.stops.len() {
914 return;
915 }
916 self.stops
917 .swap(self.selected_stop_index, self.selected_stop_index + 1);
918 self.selected_stop_index += 1;
919 }
920
921 pub fn remove_selected_stop(&mut self) {
922 if self.stops.is_empty() {
923 return;
924 }
925 let idx = self.selected_stop_index.min(self.stops.len() - 1);
926 self.stops.remove(idx);
927 self.editing_index = None;
928 if self.selected_stop_index >= self.stops.len() {
929 self.selected_stop_index = self.stops.len().saturating_sub(1);
930 }
931 }
932
933 fn find_stop(&self, pred: impl Fn(&WorkerRouteStop) -> bool) -> Option<usize> {
935 self.stops.iter().position(pred)
936 }
937
938 pub fn harvest_node_index(&self, node_id: &str) -> Option<usize> {
939 self.find_stop(|s| matches!(s, WorkerRouteStop::HarvestNode { node_id: n } if n == node_id))
940 }
941
942 pub fn deposit_container_index(&self, container_id: &str) -> Option<usize> {
943 self.find_stop(
944 |s| matches!(s, WorkerRouteStop::DepositAt { container_id: c, .. } if c == container_id),
945 )
946 }
947
948 pub fn trade_stop_index(&self, npc_id: Option<&str>, template: &str) -> Option<usize> {
949 self.find_stop(|s| {
950 matches!(s, WorkerRouteStop::TradeWith { npc_id: n, template: t, .. }
951 if n.as_deref() == npc_id && t == template)
952 })
953 }
954
955 pub fn insert_stop(&mut self, stop: WorkerRouteStop) -> (bool, usize) {
959 let existing = match &stop {
960 WorkerRouteStop::HarvestNode { node_id } => self.harvest_node_index(node_id),
961 WorkerRouteStop::DepositAt { container_id, .. } => {
962 self.deposit_container_index(container_id)
963 }
964 WorkerRouteStop::TradeWith {
965 npc_id, template, ..
966 } => self.trade_stop_index(npc_id.as_deref(), template),
967 _ => None,
968 };
969 if let Some(idx) = existing {
970 self.selected_stop_index = idx;
971 return (false, idx);
972 }
973 self.stops.push(stop);
974 self.selected_stop_index = self.stops.len() - 1;
975 (true, self.stops.len() - 1)
976 }
977
978 pub fn append_waypoint(&mut self, x: f32, y: f32, z: f32) {
979 self.insert_stop(WorkerRouteStop::Waypoint { x, y, z });
980 }
981
982 pub fn append_harvest_node(&mut self, node_id: &str) -> bool {
983 self.insert_stop(WorkerRouteStop::HarvestNode {
984 node_id: node_id.to_string(),
985 })
986 .0
987 }
988
989 pub fn append_deposit_at(&mut self, container_id: &str) -> bool {
990 self.insert_stop(WorkerRouteStop::DepositAt {
991 container_id: container_id.to_string(),
992 filter: None,
993 })
994 .0
995 }
996
997 pub fn append_deposit_at_filtered(
1000 &mut self,
1001 container_id: &str,
1002 filter_templates: Vec<String>,
1003 ) {
1004 self.stops.push(WorkerRouteStop::DepositAt {
1005 container_id: container_id.to_string(),
1006 filter: Some(filter_templates),
1007 });
1008 self.selected_stop_index = self.stops.len() - 1;
1009 }
1010
1011 pub fn append_rest_if_needed(&mut self) {
1012 self.insert_stop(WorkerRouteStop::RestIfNeeded);
1013 }
1014
1015 pub fn append_wait(&mut self, wait_ticks: u64) {
1016 self.insert_stop(WorkerRouteStop::Wait { wait_ticks });
1017 }
1018
1019 pub fn append_trade_with(
1023 &mut self,
1024 template: String,
1025 npc_id: Option<String>,
1026 sell_all: bool,
1027 ) -> bool {
1028 self.insert_stop(WorkerRouteStop::TradeWith {
1029 npc_id,
1030 template,
1031 sell_all,
1032 })
1033 .0
1034 }
1035
1036 pub fn set_selected_trade_npc(&mut self, npc_id: String) -> bool {
1041 let Some(stop) = self.stops.get_mut(self.selected_stop_index) else {
1042 return false;
1043 };
1044 let WorkerRouteStop::TradeWith {
1045 npc_id: slot,
1046 template,
1047 ..
1048 } = stop
1049 else {
1050 return false;
1051 };
1052 *slot = Some(npc_id.clone());
1053 let template = template.clone();
1054 let selected = self.selected_stop_index;
1055 if let Some(other) = self
1056 .trade_stop_index(Some(npc_id.as_str()), template.as_str())
1057 .filter(|&i| i != selected)
1058 {
1059 self.stops.remove(selected);
1060 self.selected_stop_index = if other > selected { other - 1 } else { other };
1061 }
1062 true
1063 }
1064
1065 pub fn set_selected_withdraw_container(&mut self, container_id: String) -> bool {
1069 let Some(stop) = self.stops.get_mut(self.selected_stop_index) else {
1070 return false;
1071 };
1072 if let WorkerRouteStop::WithdrawFrom {
1073 container_id: slot, ..
1074 } = stop
1075 {
1076 *slot = container_id;
1077 return true;
1078 }
1079 false
1080 }
1081
1082 pub fn retarget_withdraw_container(&mut self, container_id: String) -> bool {
1085 let idx = self.editing_index.unwrap_or(self.selected_stop_index);
1086 let Some(stop) = self.stops.get_mut(idx) else {
1087 return false;
1088 };
1089 if let WorkerRouteStop::WithdrawFrom {
1090 container_id: slot, ..
1091 } = stop
1092 {
1093 *slot = container_id;
1094 return true;
1095 }
1096 false
1097 }
1098
1099 pub fn retarget_deposit_container(&mut self, container_id: String) -> bool {
1102 let idx = self.editing_index.unwrap_or(self.selected_stop_index);
1103 let Some(stop) = self.stops.get_mut(idx) else {
1104 return false;
1105 };
1106 if let WorkerRouteStop::DepositAt {
1107 container_id: slot, ..
1108 } = stop
1109 {
1110 *slot = container_id;
1111 return true;
1112 }
1113 false
1114 }
1115
1116 pub fn open_add_menu(&mut self) {
1119 self.editing_index = None;
1120 self.sheet = RouteEditorSheet::AddMenu { index: 0 };
1121 }
1122
1123 pub fn open_sheet(&mut self, sheet: RouteEditorSheet) {
1124 self.sheet_filter.clear();
1125 self.sheet_filter_focused = false;
1126 self.sheet = sheet;
1127 }
1128
1129 pub fn confirm_harvest_picks(&mut self, node_ids: &[String]) -> usize {
1131 if node_ids.is_empty() {
1132 return 0;
1133 }
1134 let mut added = 0usize;
1135 if let Some(idx) = self.editing_index.take() {
1136 if let Some(first) = node_ids.first() {
1137 if idx < self.stops.len() {
1138 self.stops[idx] = WorkerRouteStop::HarvestNode {
1139 node_id: first.clone(),
1140 };
1141 self.selected_stop_index = idx;
1142 added = 1;
1143 }
1144 for id in node_ids.iter().skip(1) {
1145 if self
1146 .insert_stop(WorkerRouteStop::HarvestNode {
1147 node_id: id.clone(),
1148 })
1149 .0
1150 {
1151 added += 1;
1152 }
1153 }
1154 }
1155 } else {
1156 for id in node_ids {
1157 if self
1158 .insert_stop(WorkerRouteStop::HarvestNode {
1159 node_id: id.clone(),
1160 })
1161 .0
1162 {
1163 added += 1;
1164 }
1165 }
1166 }
1167 self.sheet = RouteEditorSheet::Stops;
1168 added
1169 }
1170
1171 pub fn confirm_trade_picks(
1173 &mut self,
1174 npc_id: Option<String>,
1175 templates: &[String],
1176 sell_all: bool,
1177 ) -> usize {
1178 if templates.is_empty() {
1179 return 0;
1180 }
1181 let mut added = 0usize;
1182 if let Some(idx) = self.editing_index.take() {
1183 if let Some(first) = templates.first() {
1184 if idx < self.stops.len() {
1185 self.stops[idx] = WorkerRouteStop::TradeWith {
1186 npc_id: npc_id.clone(),
1187 template: first.clone(),
1188 sell_all,
1189 };
1190 self.selected_stop_index = idx;
1191 added = 1;
1192 }
1193 for template in templates.iter().skip(1) {
1194 if self
1195 .insert_stop(WorkerRouteStop::TradeWith {
1196 npc_id: npc_id.clone(),
1197 template: template.clone(),
1198 sell_all,
1199 })
1200 .0
1201 {
1202 added += 1;
1203 }
1204 }
1205 }
1206 } else {
1207 for template in templates {
1208 if self
1209 .insert_stop(WorkerRouteStop::TradeWith {
1210 npc_id: npc_id.clone(),
1211 template: template.clone(),
1212 sell_all,
1213 })
1214 .0
1215 {
1216 added += 1;
1217 }
1218 }
1219 }
1220 self.sheet = RouteEditorSheet::Stops;
1221 added
1222 }
1223
1224 pub fn confirm_market_list_picks(
1226 &mut self,
1227 hall_id: Option<String>,
1228 templates: &[String],
1229 list_all: bool,
1230 ) -> usize {
1231 if templates.is_empty() {
1232 return 0;
1233 }
1234 let mut added = 0usize;
1235 if let Some(idx) = self.editing_index.take() {
1236 if let Some(first) = templates.first() {
1237 if idx < self.stops.len() {
1238 self.stops[idx] = WorkerRouteStop::ListOnMarket {
1239 template: first.clone(),
1240 list_all,
1241 hall_id: hall_id.clone(),
1242 };
1243 self.selected_stop_index = idx;
1244 added = 1;
1245 }
1246 for template in templates.iter().skip(1) {
1247 if self
1248 .insert_stop(WorkerRouteStop::ListOnMarket {
1249 template: template.clone(),
1250 list_all,
1251 hall_id: hall_id.clone(),
1252 })
1253 .0
1254 {
1255 added += 1;
1256 }
1257 }
1258 }
1259 } else {
1260 for template in templates {
1261 if self
1262 .insert_stop(WorkerRouteStop::ListOnMarket {
1263 template: template.clone(),
1264 list_all,
1265 hall_id: hall_id.clone(),
1266 })
1267 .0
1268 {
1269 added += 1;
1270 }
1271 }
1272 }
1273 self.sheet = RouteEditorSheet::Stops;
1274 added
1275 }
1276
1277 pub fn begin_edit_selected(&mut self) {
1280 if self.selected_stop_index < self.stops.len() {
1281 self.editing_index = Some(self.selected_stop_index);
1282 }
1283 }
1284
1285 pub fn sheet_back(&mut self) {
1289 use RouteEditorSheet as S;
1290 let editing = self.editing_index.is_some();
1291 let next = match &self.sheet {
1292 S::Stops => return,
1293 S::AddMenu { .. } | S::BedPicker { .. } | S::FarmPlotPicker { .. } => S::Stops,
1294 S::FarmPlantSeed { .. } => S::FarmPlotPicker {
1295 index: 0,
1296 action: FarmPlotAction::Plant,
1297 },
1298 S::WaypointMapPick => {
1299 if editing {
1300 S::Stops
1301 } else {
1302 S::WaypointMenu { index: 0 }
1303 }
1304 }
1305 S::WithdrawItems { .. } => S::WithdrawContainers { index: 0 },
1308 S::DepositFilter { .. } => S::DepositContainers { index: 0 },
1309 S::SellItem { .. } => S::SellNpcs { index: 0 },
1310 S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
1311 if editing =>
1312 {
1313 S::Stops
1314 }
1315 _ => {
1317 if editing {
1318 S::Stops
1319 } else {
1320 S::AddMenu { index: 0 }
1321 }
1322 }
1323 };
1324 if matches!(next, S::Stops) {
1325 self.editing_index = None;
1326 }
1327 self.sheet = next;
1328 }
1329
1330 pub fn confirm_stop(&mut self, stop: WorkerRouteStop) -> bool {
1334 let result = if let Some(idx) = self.editing_index.take() {
1335 if idx < self.stops.len() {
1336 self.stops[idx] = stop;
1337 self.selected_stop_index = idx;
1338 }
1339 true
1340 } else {
1341 self.insert_stop(stop).0
1342 };
1343 self.sheet = RouteEditorSheet::Stops;
1344 result
1345 }
1346
1347 pub fn withdraw_line_drafts(
1351 contents: &[ItemStack],
1352 existing: &[WorkerRouteWithdrawItem],
1353 ) -> Vec<WithdrawLineDraft> {
1354 let mut lines: Vec<WithdrawLineDraft> = Vec::new();
1355 for s in contents {
1356 if s.template_id.is_empty() {
1357 continue;
1358 }
1359 match lines.iter_mut().find(|l| l.template == s.template_id) {
1360 Some(l) => l.available += s.quantity,
1361 None => lines.push(WithdrawLineDraft {
1362 template: s.template_id.clone(),
1363 available: s.quantity,
1364 mode: WithdrawLineMode::Off,
1365 }),
1366 }
1367 }
1368 for item in existing {
1369 let mode = match item.qty {
1370 None => WithdrawLineMode::All,
1371 Some(q) => WithdrawLineMode::Qty(q),
1372 };
1373 match lines.iter_mut().find(|l| l.template == item.template) {
1374 Some(l) => l.mode = mode,
1375 None => lines.push(WithdrawLineDraft {
1376 template: item.template.clone(),
1377 available: 0,
1378 mode,
1379 }),
1380 }
1381 }
1382 lines.sort_by(|a, b| a.template.cmp(&b.template));
1383 lines
1384 }
1385
1386 pub fn withdraw_items_from_lines(lines: &[WithdrawLineDraft]) -> Vec<WorkerRouteWithdrawItem> {
1388 lines
1389 .iter()
1390 .filter_map(|l| match l.mode {
1391 WithdrawLineMode::Off => None,
1392 WithdrawLineMode::All => Some(WorkerRouteWithdrawItem {
1393 template: l.template.clone(),
1394 qty: None,
1395 }),
1396 WithdrawLineMode::Qty(q) => Some(WorkerRouteWithdrawItem {
1397 template: l.template.clone(),
1398 qty: Some(q),
1399 }),
1400 })
1401 .collect()
1402 }
1403
1404 fn job_id(&self) -> String {
1407 format!(
1408 "route_{}",
1409 self.worker_instance_id
1410 .chars()
1411 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
1412 .collect::<String>()
1413 )
1414 }
1415
1416 pub fn build_idle_job_yaml(&self) -> String {
1420 let job_id = self.job_id();
1423 [
1424 format!("job_id: {job_id}"),
1425 "mode: idle".into(),
1426 "steps: []".into(),
1427 ]
1428 .join("\n")
1429 }
1430
1431 pub fn build_job_yaml(&self) -> Result<String, String> {
1432 if self.stops.is_empty() {
1433 return Err("add at least one stop (waypoint, harvest node, or deposit)".into());
1434 }
1435 let job_id = self.job_id();
1436 let mut lines = vec![
1437 format!("job_id: {job_id}"),
1438 "mode: job_loop".into(),
1439 "route:".into(),
1440 " kind: ordered".into(),
1441 ];
1442 if let Some(lodging) = &self.lodging_container_id {
1443 lines.push(format!(" lodging_container_id: {lodging}"));
1444 }
1445 lines.push(format!(
1446 " carry_return_ratio: {:.2}",
1447 self.carry_return_ratio
1448 ));
1449 lines.push(" stops:".into());
1450 for stop in &self.stops {
1451 match stop {
1452 WorkerRouteStop::Waypoint { x, y, z } => {
1453 lines.push(format!(
1454 " - {{ stop: waypoint, x: {:.1}, y: {:.1}, z: {:.1} }}",
1455 x, y, z
1456 ));
1457 }
1458 WorkerRouteStop::HarvestNode { node_id } => {
1459 lines.push(format!(
1460 " - {{ stop: harvest_node, node_id: {node_id} }}"
1461 ));
1462 }
1463 WorkerRouteStop::DepositAt {
1464 container_id,
1465 filter,
1466 } => {
1467 let f = filter
1468 .as_ref()
1469 .filter(|f| !f.is_empty())
1470 .map(|f| {
1471 format!(
1472 ", filter: [{}]",
1473 f.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
1474 )
1475 })
1476 .unwrap_or_default();
1477 lines.push(format!(
1478 " - {{ stop: deposit_at, container_id: {container_id}{f} }}"
1479 ));
1480 }
1481 WorkerRouteStop::RestIfNeeded => {
1482 lines.push(" - { stop: rest_if_needed }".into());
1483 }
1484 WorkerRouteStop::Wait { wait_ticks } => {
1485 lines.push(format!(" - {{ stop: wait, wait_ticks: {wait_ticks} }}"));
1486 }
1487 WorkerRouteStop::TradeWith {
1488 npc_id,
1489 template,
1490 sell_all,
1491 } => {
1492 let who = npc_id
1493 .as_deref()
1494 .map(|n| format!(", npc_id: {n}"))
1495 .unwrap_or_default();
1496 lines.push(format!(
1497 " - {{ stop: trade_with, template: {template}{who}, sell_all: {sell_all} }}"
1498 ));
1499 }
1500 WorkerRouteStop::ListOnMarket {
1501 template,
1502 list_all,
1503 hall_id,
1504 } => {
1505 let hall = hall_id
1506 .as_deref()
1507 .map(|h| format!(", hall_id: {h}"))
1508 .unwrap_or_default();
1509 lines.push(format!(
1510 " - {{ stop: list_on_market, template: {template}{hall}, list_all: {list_all} }}"
1511 ));
1512 }
1513 WorkerRouteStop::WithdrawFrom {
1514 container_id,
1515 items,
1516 } => {
1517 let mut block = format!(
1518 " - stop: withdraw_from\n container_id: {container_id}\n items:"
1519 );
1520 for it in items {
1521 let line = match it.qty {
1522 None => {
1523 format!("\n - {{ template: {}, all: true }}", it.template)
1524 }
1525 Some(q) => {
1526 format!("\n - {{ template: {}, qty: {} }}", it.template, q)
1527 }
1528 };
1529 block.push_str(&line);
1530 }
1531 lines.push(block);
1532 }
1533 WorkerRouteStop::CraftAt {
1534 device,
1535 blueprint,
1536 qty,
1537 } => {
1538 let qty_str = qty.map(|q| format!(", qty: {q}")).unwrap_or_default();
1539 lines.push(format!(
1540 " - {{ stop: craft_at, device: {device}, blueprint: {blueprint}{qty_str} }}"
1541 ));
1542 }
1543 WorkerRouteStop::CultivatePlot { plot_id } => {
1544 lines.push(format!(
1545 " - {{ stop: cultivate_plot, plot_id: \"{plot_id}\" }}"
1546 ));
1547 }
1548 WorkerRouteStop::PlantPlot {
1549 plot_id,
1550 seed_template,
1551 } => {
1552 lines.push(format!(
1553 " - {{ stop: plant_plot, plot_id: \"{plot_id}\", seed_template: {seed_template} }}"
1554 ));
1555 }
1556 WorkerRouteStop::HarvestPlot { plot_id } => {
1557 lines.push(format!(
1558 " - {{ stop: harvest_plot, plot_id: \"{plot_id}\" }}"
1559 ));
1560 }
1561 }
1562 }
1563 lines.push("steps: []".into());
1564 Ok(lines.join("\n"))
1565 }
1566
1567 pub fn to_route_view(&self) -> flatland_protocol::WorkerRouteView {
1569 use flatland_protocol::{
1570 WorkerRouteKindView, WorkerRouteStopView, WorkerRouteView, WorkerWithdrawItemView,
1571 };
1572 WorkerRouteView {
1573 kind: WorkerRouteKindView::Ordered,
1574 lodging_container_id: self.lodging_container_id.clone(),
1575 outbound_waypoints: Vec::new(),
1576 harvest_nodes: Vec::new(),
1577 carry_return_ratio: self.carry_return_ratio,
1578 stops: self
1579 .stops
1580 .iter()
1581 .map(|stop| match stop {
1582 WorkerRouteStop::Waypoint { x, y, z } => WorkerRouteStopView::Waypoint {
1583 x: *x,
1584 y: *y,
1585 z: *z,
1586 },
1587 WorkerRouteStop::HarvestNode { node_id } => WorkerRouteStopView::HarvestNode {
1588 node_id: node_id.clone(),
1589 },
1590 WorkerRouteStop::DepositAt {
1591 container_id,
1592 filter,
1593 } => WorkerRouteStopView::DepositAt {
1594 container_id: container_id.clone(),
1595 filter: filter.clone(),
1596 },
1597 WorkerRouteStop::TradeWith {
1598 npc_id,
1599 template,
1600 sell_all,
1601 } => WorkerRouteStopView::TradeWith {
1602 npc_id: npc_id.clone(),
1603 template: template.clone(),
1604 sell_all: *sell_all,
1605 },
1606 WorkerRouteStop::ListOnMarket {
1607 template,
1608 list_all,
1609 hall_id,
1610 } => WorkerRouteStopView::ListOnMarket {
1611 template: template.clone(),
1612 list_all: *list_all,
1613 hall_id: hall_id.clone(),
1614 },
1615 WorkerRouteStop::WithdrawFrom {
1616 container_id,
1617 items,
1618 } => WorkerRouteStopView::WithdrawFrom {
1619 container_id: container_id.clone(),
1620 items: items
1621 .iter()
1622 .map(|i| WorkerWithdrawItemView {
1623 template: i.template.clone(),
1624 qty: i.qty.unwrap_or(0),
1625 all: i.qty.is_none(),
1626 })
1627 .collect(),
1628 },
1629 WorkerRouteStop::CraftAt {
1630 device,
1631 blueprint,
1632 qty,
1633 } => WorkerRouteStopView::CraftAt {
1634 device: device.clone(),
1635 blueprint: blueprint.clone(),
1636 qty: *qty,
1637 },
1638 WorkerRouteStop::CultivatePlot { plot_id } => {
1639 WorkerRouteStopView::CultivatePlot { plot_id: *plot_id }
1640 }
1641 WorkerRouteStop::PlantPlot {
1642 plot_id,
1643 seed_template,
1644 } => WorkerRouteStopView::PlantPlot {
1645 plot_id: *plot_id,
1646 seed_template: seed_template.clone(),
1647 },
1648 WorkerRouteStop::HarvestPlot { plot_id } => {
1649 WorkerRouteStopView::HarvestPlot { plot_id: *plot_id }
1650 }
1651 WorkerRouteStop::RestIfNeeded => WorkerRouteStopView::RestIfNeeded,
1652 WorkerRouteStop::Wait { wait_ticks } => WorkerRouteStopView::Wait {
1653 wait_ticks: *wait_ticks,
1654 },
1655 })
1656 .collect(),
1657 }
1658 }
1659}
1660
1661fn stop_view_to_stop(view: &WorkerRouteStopView) -> WorkerRouteStop {
1662 match view {
1663 WorkerRouteStopView::Waypoint { x, y, z } => WorkerRouteStop::Waypoint {
1664 x: *x,
1665 y: *y,
1666 z: *z,
1667 },
1668 WorkerRouteStopView::HarvestNode { node_id } => WorkerRouteStop::HarvestNode {
1669 node_id: node_id.clone(),
1670 },
1671 WorkerRouteStopView::DepositAt {
1672 container_id,
1673 filter,
1674 } => WorkerRouteStop::DepositAt {
1675 container_id: container_id.clone(),
1676 filter: filter.clone(),
1677 },
1678 WorkerRouteStopView::TradeWith {
1679 npc_id,
1680 template,
1681 sell_all,
1682 } => WorkerRouteStop::TradeWith {
1683 npc_id: npc_id.clone(),
1684 template: template.clone(),
1685 sell_all: *sell_all,
1686 },
1687 WorkerRouteStopView::ListOnMarket {
1688 template,
1689 list_all,
1690 hall_id,
1691 } => WorkerRouteStop::ListOnMarket {
1692 template: template.clone(),
1693 list_all: *list_all,
1694 hall_id: hall_id.clone(),
1695 },
1696 WorkerRouteStopView::WithdrawFrom {
1697 container_id,
1698 items,
1699 } => WorkerRouteStop::WithdrawFrom {
1700 container_id: container_id.clone(),
1701 items: items
1702 .iter()
1703 .map(|i| WorkerRouteWithdrawItem {
1704 template: i.template.clone(),
1705 qty: if i.all { None } else { Some(i.qty) },
1706 })
1707 .collect(),
1708 },
1709 WorkerRouteStopView::CraftAt {
1710 device,
1711 blueprint,
1712 qty,
1713 } => WorkerRouteStop::CraftAt {
1714 device: device.clone(),
1715 blueprint: blueprint.clone(),
1716 qty: *qty,
1717 },
1718 WorkerRouteStopView::CultivatePlot { plot_id } => {
1719 WorkerRouteStop::CultivatePlot { plot_id: *plot_id }
1720 }
1721 WorkerRouteStopView::PlantPlot {
1722 plot_id,
1723 seed_template,
1724 } => WorkerRouteStop::PlantPlot {
1725 plot_id: *plot_id,
1726 seed_template: seed_template.clone(),
1727 },
1728 WorkerRouteStopView::HarvestPlot { plot_id } => {
1729 WorkerRouteStop::HarvestPlot { plot_id: *plot_id }
1730 }
1731 WorkerRouteStopView::RestIfNeeded => WorkerRouteStop::RestIfNeeded,
1732 WorkerRouteStopView::Wait { wait_ticks } => WorkerRouteStop::Wait {
1733 wait_ticks: *wait_ticks,
1734 },
1735 }
1736}
1737
1738const HARVEST_NODE_PICK_M: f32 = 4.0;
1741const LODGING_PICK_M: f32 = 5.0;
1742const STORAGE_PICK_M: f32 = 5.0;
1743
1744fn dist2d(x0: f32, y0: f32, x1: f32, y1: f32) -> f32 {
1745 let dx = x0 - x1;
1746 let dy = y0 - y1;
1747 (dx * dx + dy * dy).sqrt()
1748}
1749
1750pub fn pick_resource_node_at<'a>(
1752 nodes: &'a [ResourceNodeView],
1753 x: f32,
1754 y: f32,
1755) -> Option<&'a ResourceNodeView> {
1756 nodes
1757 .iter()
1758 .filter(|n| is_harvest_route_node(n))
1759 .filter_map(|n| {
1760 let d = dist2d(x, y, n.x, n.y);
1761 if d <= HARVEST_NODE_PICK_M {
1762 Some((d, n))
1763 } else {
1764 None
1765 }
1766 })
1767 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1768 .map(|(_, n)| n)
1769}
1770
1771pub fn owned_lodging_container_ids(
1773 placed: &[PlacedContainerView],
1774 character_id: Option<uuid::Uuid>,
1775) -> Vec<(String, String)> {
1776 owned_lodging_container_ids_with_occupants(placed, character_id, &[])
1777}
1778
1779pub fn owned_lodging_container_ids_with_occupants(
1781 placed: &[PlacedContainerView],
1782 character_id: Option<uuid::Uuid>,
1783 hired: &[flatland_protocol::HiredWorkerView],
1784) -> Vec<(String, String)> {
1785 let Some(cid) = character_id else {
1786 return Vec::new();
1787 };
1788 let mut out: Vec<(String, String)> = placed
1789 .iter()
1790 .filter(|c| c.worker_lodging_capacity.unwrap_or(0) > 0)
1791 .filter(|c| c.owner_character_id == Some(cid))
1792 .map(|c| {
1793 let who = lodging_occupants_for(hired, &c.id);
1794 let name = if who.is_empty() {
1795 format!("{} — vacant", c.display_name)
1796 } else {
1797 format!("{} — {}", c.display_name, who.join(", "))
1798 };
1799 (c.id.clone(), name)
1800 })
1801 .collect();
1802 out.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
1803 out
1804}
1805
1806pub fn pick_lodging_container_at(
1807 placed: &[PlacedContainerView],
1808 character_id: Option<uuid::Uuid>,
1809 x: f32,
1810 y: f32,
1811 observer_inside: Option<&str>,
1812) -> Option<String> {
1813 let cid = character_id?;
1814 placed
1815 .iter()
1816 .filter(|c| container_in_observer_space(c, observer_inside))
1817 .filter(|c| c.worker_lodging_capacity.unwrap_or(0) > 0)
1818 .filter(|c| c.owner_character_id == Some(cid))
1819 .filter_map(|c| {
1820 let d = dist2d(x, y, c.x, c.y);
1821 if d <= LODGING_PICK_M {
1822 Some((d, c.id.clone()))
1823 } else {
1824 None
1825 }
1826 })
1827 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1828 .map(|(_, id)| id)
1829}
1830
1831pub fn pick_trade_npc_at(npcs: &[NpcView], x: f32, y: f32) -> Option<(String, String)> {
1833 const NPC_PICK_M: f32 = 5.0;
1834 npcs.iter()
1835 .filter(|n| n.can_trade)
1836 .filter_map(|n| {
1837 let d = dist2d(x, y, n.x, n.y);
1838 if d <= NPC_PICK_M {
1839 Some((d, n.id.clone(), n.label.clone()))
1840 } else {
1841 None
1842 }
1843 })
1844 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1845 .map(|(_, id, label)| (id, label))
1846}
1847
1848pub fn owned_storage_template_ids(
1851 placed: &[PlacedContainerView],
1852 character_id: Option<uuid::Uuid>,
1853) -> Vec<String> {
1854 let Some(cid) = character_id else {
1855 return Vec::new();
1856 };
1857 let mut out: Vec<String> = placed
1858 .iter()
1859 .filter(|c| c.owner_character_id == Some(cid))
1860 .flat_map(|c| c.contents.iter().map(|s| s.template_id.clone()))
1861 .filter(|t| !t.is_empty())
1862 .collect();
1863 out.sort();
1864 out.dedup();
1865 out
1866}
1867
1868pub fn worker_craft_blueprint_ids(
1875 blueprints: &[flatland_protocol::BlueprintView],
1876 known_blueprint_ids: Option<&[String]>,
1877) -> Vec<String> {
1878 let mut ids: Vec<String> = blueprints.iter().map(|b| b.id.clone()).collect();
1879 if let Some(known) = known_blueprint_ids {
1880 if !known.is_empty() {
1881 ids.retain(|id| known.iter().any(|k| k == id));
1882 }
1883 }
1884 ids
1885}
1886
1887pub fn route_item_template_candidates(
1893 placed: &[PlacedContainerView],
1894 character_id: Option<uuid::Uuid>,
1895 inventory: &std::collections::HashMap<String, u32>,
1896 blueprints: &[flatland_protocol::BlueprintView],
1897 resource_nodes: &[flatland_protocol::ResourceNodeView],
1898 extra: &[String],
1899 catalog: Option<&std::collections::HashMap<String, ItemCatalogEntryView>>,
1900) -> Vec<String> {
1901 let mut out = owned_storage_template_ids(placed, character_id);
1902 for (template, qty) in inventory {
1903 if *qty > 0 && !template.is_empty() {
1904 out.push(template.clone());
1905 }
1906 }
1907 for bp in blueprints {
1908 if !bp.output.is_empty() {
1909 out.push(bp.output.clone());
1910 }
1911 for input in &bp.inputs {
1912 if !input.template_id.is_empty() {
1913 out.push(input.template_id.clone());
1914 }
1915 }
1916 for tool in &bp.required_tools {
1917 if !tool.item.is_empty() {
1918 out.push(tool.item.clone());
1919 }
1920 }
1921 }
1922 for node in resource_nodes {
1923 for t in &node.harvest_drop_templates {
1925 if !t.is_empty() {
1926 out.push(t.clone());
1927 }
1928 }
1929 }
1930 for t in extra {
1931 if !t.is_empty() {
1932 out.push(t.clone());
1933 }
1934 }
1935 out.sort();
1936 out.dedup();
1937 if let Some(catalog) = catalog {
1938 out.retain(|id| catalog.get(id).is_none_or(|e| e.is_depositable_stack()));
1939 }
1940 out
1941}
1942
1943pub fn sellable_route_item_template_candidates(
1949 templates: &[String],
1950 npcs: &[flatland_protocol::NpcView],
1951 npc_id: Option<&str>,
1952) -> Vec<String> {
1953 let accepted = npcs
1954 .iter()
1955 .filter(|npc| npc_id.is_none_or(|id| npc.id == id))
1956 .filter(|npc| npc_id.is_some() || npc.can_trade)
1957 .flat_map(|npc| npc.buy_templates.iter().map(String::as_str))
1958 .collect::<std::collections::HashSet<_>>();
1959 let mut out: Vec<String> = templates
1960 .iter()
1961 .filter(|template| accepted.contains(template.as_str()))
1962 .cloned()
1963 .collect();
1964 out.sort();
1965 out.dedup();
1966 out
1967}
1968
1969pub fn pick_storage_container_at(
1973 placed: &[PlacedContainerView],
1974 character_id: Option<uuid::Uuid>,
1975 x: f32,
1976 y: f32,
1977 observer_inside: Option<&str>,
1978) -> Option<String> {
1979 let cid = character_id?;
1980 placed
1981 .iter()
1982 .filter(|c| container_in_observer_space(c, observer_inside))
1983 .filter(|c| c.owner_character_id == Some(cid))
1984 .filter(|c| c.capacity_volume.unwrap_or(0.0) > 0.0)
1985 .filter_map(|c| {
1986 let d = dist2d(x, y, c.x, c.y);
1987 if d <= STORAGE_PICK_M {
1988 Some((d, c.id.clone()))
1989 } else {
1990 None
1991 }
1992 })
1993 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1994 .map(|(_, id)| id)
1995}
1996
1997fn container_in_observer_space(c: &PlacedContainerView, observer_inside: Option<&str>) -> bool {
1998 match (observer_inside, c.building_id.as_deref()) {
1999 (None, None) => true,
2000 (Some(a), Some(b)) => a == b,
2001 _ => false,
2002 }
2003}
2004
2005#[cfg(test)]
2006mod tests {
2007 use super::*;
2008
2009 #[test]
2010 fn worker_craft_blueprint_ids_filters_to_known_recipes() {
2011 use flatland_protocol::{BlueprintIngredientView, BlueprintView};
2012
2013 fn bp(id: &str) -> BlueprintView {
2014 BlueprintView {
2015 id: id.into(),
2016 label: id.into(),
2017 output: "x".into(),
2018 output_qty: 1,
2019 craft_ticks: 1,
2020 inputs: vec![BlueprintIngredientView {
2021 template_id: "oak_log".into(),
2022 quantity: 1,
2023 consumed: true,
2024 display_name: "Oak Log".into(),
2025 }],
2026 station: Some("hand".into()),
2027 category: None,
2028 required_tools: vec![],
2029 skill: None,
2030 failure_chance: 0.0,
2031 worker_train_copper: 0,
2032 output_display_name: "X".into(),
2033 craft_tier: 1,
2034 }
2035 }
2036
2037 let all = vec![
2038 bp("oak_to_lumber"),
2039 bp("vegetable_soup"),
2040 bp("craft_simple_camp_bed"),
2041 bp("craft_wooden_chest_small"),
2042 bp("iron_ingot"),
2043 ];
2044 let known = vec![
2045 "oak_to_lumber".into(),
2046 "craft_simple_camp_bed".into(),
2047 "craft_wooden_chest_small".into(),
2048 ];
2049
2050 let filtered = worker_craft_blueprint_ids(&all, Some(&known));
2051 assert_eq!(
2052 filtered,
2053 vec![
2054 "oak_to_lumber",
2055 "craft_simple_camp_bed",
2056 "craft_wooden_chest_small"
2057 ]
2058 );
2059
2060 assert_eq!(worker_craft_blueprint_ids(&all, Some(&[])).len(), all.len());
2062 assert_eq!(worker_craft_blueprint_ids(&all, None).len(), all.len());
2063 }
2064
2065 #[test]
2066 fn route_item_candidates_include_craft_outputs_not_in_storage() {
2067 use flatland_protocol::{
2068 BlueprintIngredientView, BlueprintView, PlacedContainerView, ResourceNodeState,
2069 ResourceNodeView,
2070 };
2071 use std::collections::HashMap;
2072 use uuid::Uuid;
2073
2074 let cid = Uuid::from_u128(0x1111_2222_3333_4444_5555_6666_7777_8888);
2075 let placed = vec![PlacedContainerView {
2076 id: "chest-1".into(),
2077 template_id: "wooden_chest_small".into(),
2078 display_name: "Chest".into(),
2079 x: 0.0,
2080 y: 0.0,
2081 z: 0.0,
2082 locked: false,
2083 accessible: true,
2084 owner_character_id: Some(cid),
2085 contents: vec![],
2086 lock_id: None,
2087 capacity_volume: Some(20.0),
2088 item_instance_id: None,
2089 tile_id: None,
2090 worker_lodging_capacity: None,
2091 blocking: false,
2092 blocking_radius_m: 0.0,
2093 building_id: None,
2094 }];
2095 let blueprints = vec![BlueprintView {
2096 id: "smelt_iron".into(),
2097 label: "Smelt Iron".into(),
2098 output: "iron_ingot".into(),
2099 output_qty: 1,
2100 craft_ticks: 30,
2101 inputs: vec![BlueprintIngredientView {
2102 template_id: "iron_ore".into(),
2103 quantity: 1,
2104 consumed: true,
2105 display_name: "Iron Ore".into(),
2106 }],
2107 station: Some("hand".into()),
2108 category: None,
2109 required_tools: vec![],
2110 skill: None,
2111 failure_chance: 0.0,
2112 worker_train_copper: 0,
2113 output_display_name: "Iron Ingot".into(),
2114 craft_tier: 1,
2115 }];
2116 let nodes = vec![ResourceNodeView {
2117 id: "ore-1".into(),
2118 label: "Iron Ore".into(),
2119 x: 1.0,
2120 y: 1.0,
2121 z: 0.0,
2122 item_template: "iron_ore".into(),
2123 state: ResourceNodeState::Available,
2124 blocking: true,
2125 blocking_radius_m: 0.8,
2126 harvest_off: false,
2127 tile_id: None,
2128 yaw: 0.0,
2129 pitch: 0.0,
2130 roll: 0.0,
2131 draw_scale: 1.0,
2132 sprite_mode: None,
2133 growth_progress: None,
2134 presentation_state: None,
2135 channel_start_tick: None,
2136 channel_end_tick: None,
2137 harvest_drop_templates: vec![],
2138 }];
2139 let ids = route_item_template_candidates(
2140 &placed,
2141 Some(cid),
2142 &HashMap::new(),
2143 &blueprints,
2144 &nodes,
2145 &[],
2146 None,
2147 );
2148 assert!(
2149 ids.contains(&"iron_ingot".to_string()),
2150 "craft output should be selectable before any exists in storage: {ids:?}"
2151 );
2152 assert!(ids.contains(&"iron_ore".to_string()));
2153 }
2154
2155 #[test]
2156 fn sellable_route_candidates_follow_npc_buy_lists() {
2157 use flatland_protocol::NpcView;
2158
2159 fn npc(id: &str, can_trade: bool, buy_templates: &[&str]) -> NpcView {
2160 NpcView {
2161 id: id.into(),
2162 label: id.into(),
2163 role: "merchant".into(),
2164 x: 0.0,
2165 y: 0.0,
2166 building_id: None,
2167 entity_id: None,
2168 life_state: None,
2169 hp_pct: None,
2170 can_trade,
2171 buy_templates: buy_templates.iter().map(|t| (*t).into()).collect(),
2172 tile_id: None,
2173 behavior_state: None,
2174 presentation_state: None,
2175 sprite_mode: None,
2176 paperdoll_ref: None,
2177 draw_scale: 1.0,
2178 yaw: None,
2179 perception_fov_deg: None,
2180 perception_sight_m: None,
2181 perception_hear_m: None,
2182 quest_verbs: Vec::new(),
2183 }
2184 }
2185
2186 let templates = vec![
2187 "carrot".into(),
2188 "carrot_wild".into(),
2189 "carrot_seed".into(),
2190 "oak_log".into(),
2191 ];
2192 let npcs = vec![
2193 npc("maris", true, &["carrot"]),
2194 npc("eli", true, &["carrot_seed"]),
2195 npc("wildlife", false, &["oak_log"]),
2196 ];
2197
2198 assert_eq!(
2199 sellable_route_item_template_candidates(&templates, &npcs, Some("maris")),
2200 vec!["carrot"]
2201 );
2202 assert_eq!(
2203 sellable_route_item_template_candidates(&templates, &npcs, None),
2204 vec!["carrot", "carrot_seed"]
2205 );
2206 }
2207
2208 #[test]
2209 fn trade_npc_candidates_mark_non_overlapping_merchants() {
2210 use flatland_protocol::NpcView;
2211
2212 fn npc(id: &str, label: &str, can_trade: bool, buy_templates: &[&str], x: f32) -> NpcView {
2213 NpcView {
2214 id: id.into(),
2215 label: label.into(),
2216 role: "merchant".into(),
2217 x,
2218 y: 0.0,
2219 building_id: None,
2220 entity_id: None,
2221 life_state: None,
2222 hp_pct: None,
2223 can_trade,
2224 buy_templates: buy_templates.iter().map(|t| (*t).into()).collect(),
2225 tile_id: None,
2226 behavior_state: None,
2227 presentation_state: None,
2228 sprite_mode: None,
2229 paperdoll_ref: None,
2230 draw_scale: 1.0,
2231 yaw: None,
2232 perception_fov_deg: None,
2233 perception_sight_m: None,
2234 perception_hear_m: None,
2235 quest_verbs: Vec::new(),
2236 }
2237 }
2238
2239 let route = vec!["carrot".into(), "potato".into()];
2240 let npcs = vec![
2241 npc("mira_market", "Mira", true, &[], 10.0),
2242 npc("ada_broker", "Ada", true, &["lumber", "oak_log"], 5.0),
2243 npc("maris_cook", "Maris", true, &["carrot"], 20.0),
2244 npc("wildlife", "Wolf", false, &["carrot"], 1.0),
2245 ];
2246 let cands = trade_npc_candidates(&npcs, 0.0, 0.0, &route);
2247 assert_eq!(cands.len(), 3, "only can_trade NPCs: {cands:?}");
2248 assert!(cands[0].buys_route_item && cands[0].id == "maris_cook");
2249 assert!(!cands.iter().any(|c| c.id == "wildlife"));
2250 let ada = cands.iter().find(|c| c.id == "ada_broker").unwrap();
2251 assert!(!ada.buys_route_item);
2252 let mira = cands.iter().find(|c| c.id == "mira_market").unwrap();
2253 assert!(!mira.buys_route_item);
2254 assert!(any_trade_npc_buys_route_item(&npcs, &route));
2255 assert!(!any_trade_npc_buys_route_item(
2256 &npcs,
2257 &["iron_ingot".into()]
2258 ));
2259 assert!(
2260 sell_merchant_empty_reason(Some("mira_market"), &npcs, &route)
2261 .contains("empty buy list")
2262 );
2263 assert!(
2264 sell_merchant_empty_reason(Some("ada_broker"), &npcs, &route)
2265 .contains("doesn't buy any of your route items")
2266 );
2267 assert!(
2268 sell_merchant_empty_reason(None, &npcs, &["iron_ingot".into()])
2269 .contains("no trade NPC buys")
2270 );
2271 }
2272
2273 #[test]
2274 fn route_item_template_includes_harvest_loot_table_drops() {
2275 use flatland_protocol::{ResourceNodeState, ResourceNodeView};
2276 use std::collections::HashMap;
2277 let nodes = vec![ResourceNodeView {
2278 id: "crop-carrot-1".into(),
2279 label: "Wild carrots".into(),
2280 x: 1.0,
2281 y: 1.0,
2282 z: 0.0,
2283 item_template: "carrot_wild".into(),
2284 state: ResourceNodeState::Available,
2285 blocking: false,
2286 blocking_radius_m: 0.8,
2287 harvest_off: false,
2288 tile_id: None,
2289 yaw: 0.0,
2290 pitch: 0.0,
2291 roll: 0.0,
2292 draw_scale: 1.0,
2293 sprite_mode: None,
2294 growth_progress: None,
2295 presentation_state: None,
2296 channel_start_tick: None,
2297 channel_end_tick: None,
2298 harvest_drop_templates: vec!["carrot".into(), "carrot_seed".into()],
2299 }];
2300 let ids =
2301 route_item_template_candidates(&[], None, &HashMap::new(), &[], &nodes, &[], None);
2302 assert!(
2303 ids.contains(&"carrot_seed".to_string()),
2304 "deposit filter should list seeds from harvest loot tables: {ids:?}"
2305 );
2306 assert!(
2307 !ids.contains(&"carrot_wild".to_string()),
2308 "harvest_node templates are not deposit stacks: {ids:?}"
2309 );
2310 }
2311
2312 #[test]
2313 fn route_item_candidates_drop_harvest_node_catalog_entries() {
2314 use flatland_protocol::{ResourceNodeState, ResourceNodeView};
2315 use std::collections::HashMap;
2316 let rocks = "3ee51931-189c-4726-828b-dffb1a3d1fc4";
2317 let stone = "592fe396-cc6e-42d8-8554-4080c3b19036";
2318 let nodes = vec![ResourceNodeView {
2319 id: "rocks-1".into(),
2320 label: "Rocks".into(),
2321 x: 1.0,
2322 y: 1.0,
2323 z: 0.0,
2324 item_template: rocks.into(),
2325 state: ResourceNodeState::Available,
2326 blocking: false,
2327 blocking_radius_m: 0.8,
2328 harvest_off: false,
2329 tile_id: None,
2330 yaw: 0.0,
2331 pitch: 0.0,
2332 roll: 0.0,
2333 draw_scale: 1.0,
2334 sprite_mode: None,
2335 growth_progress: None,
2336 presentation_state: None,
2337 channel_start_tick: None,
2338 channel_end_tick: None,
2339 harvest_drop_templates: vec![stone.into()],
2340 }];
2341 let mut catalog = HashMap::new();
2342 catalog.insert(
2343 rocks.to_string(),
2344 ItemCatalogEntryView {
2345 template_id: rocks.into(),
2346 display_name: "Rocks".into(),
2347 category: "harvest_node".into(),
2348 seed_for: None,
2349 },
2350 );
2351 catalog.insert(
2352 stone.to_string(),
2353 ItemCatalogEntryView {
2354 template_id: stone.into(),
2355 display_name: "Rough Stone".into(),
2356 category: "resource".into(),
2357 seed_for: None,
2358 },
2359 );
2360 let ids = route_item_template_candidates(
2361 &[],
2362 None,
2363 &HashMap::new(),
2364 &[],
2365 &nodes,
2366 &[rocks.into()],
2367 Some(&catalog),
2368 );
2369 assert!(
2370 ids.contains(&stone.to_string()),
2371 "loot drops stay selectable: {ids:?}"
2372 );
2373 assert!(
2374 !ids.contains(&rocks.to_string()),
2375 "Rocks harvest_node must not appear in deposit filter: {ids:?}"
2376 );
2377 }
2378
2379 #[test]
2380 fn build_ordered_job_yaml_includes_stops() {
2381 let mut ed = WorkerRouteEditorState::new(
2382 "worker-worker_laborer-1".into(),
2383 "Laborer".into(),
2384 Some("chest-bed".into()),
2385 );
2386 ed.append_waypoint(10.0, 20.0, 0.0);
2387 ed.append_harvest_node("oak-n1");
2388 ed.append_deposit_at("chest-storage-a");
2389 ed.append_rest_if_needed();
2390 let yaml = ed.build_job_yaml().expect("yaml");
2391 assert!(yaml.contains("kind: ordered"));
2392 assert!(yaml.contains("lodging_container_id: chest-bed"));
2393 assert!(yaml.contains("stop: waypoint"));
2394 assert!(yaml.contains("oak-n1"));
2395 assert!(yaml.contains("deposit_at"));
2396 assert!(yaml.contains("chest-storage-a"));
2397 assert!(yaml.contains("rest_if_needed"));
2398 }
2399
2400 #[test]
2401 fn requires_at_least_one_stop() {
2402 let ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2403 assert!(ed.build_job_yaml().is_err());
2404 }
2405
2406 #[test]
2407 fn reorder_stops() {
2408 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2409 ed.append_harvest_node("oak-a");
2410 ed.append_harvest_node("oak-b");
2411 ed.append_waypoint(5.0, 6.0, 0.0);
2412 ed.select_stop(1);
2414 ed.move_selected_up();
2415 assert!(
2416 matches!(&ed.stops[0], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
2417 );
2418 ed.move_selected_down();
2420 assert!(
2421 matches!(&ed.stops[1], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
2422 );
2423 }
2424
2425 #[test]
2426 fn duplicate_harvest_node_selects_existing_instead() {
2427 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2428 assert!(ed.append_harvest_node("oak-a"));
2429 ed.append_waypoint(1.0, 2.0, 0.0);
2430 assert!(!ed.append_harvest_node("oak-a"));
2431 assert_eq!(ed.stops.len(), 2);
2432 assert_eq!(ed.selected_stop_index, 0);
2433 }
2434
2435 #[test]
2436 fn duplicate_deposit_container_selects_existing_instead() {
2437 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2438 assert!(ed.append_deposit_at("chest-1"));
2439 ed.append_harvest_node("oak-a");
2440 assert!(!ed.append_deposit_at("chest-1"));
2441 assert_eq!(ed.stops.len(), 2);
2442 assert_eq!(ed.selected_stop_index, 0);
2443 }
2444
2445 #[test]
2446 fn duplicate_trade_stop_selects_existing_instead() {
2447 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2448 assert!(ed.append_trade_with("oak_log".into(), Some("ada".into()), true));
2449 assert!(!ed.append_trade_with("oak_log".into(), Some("ada".into()), true));
2450 assert!(ed.append_trade_with("lumber".into(), Some("ada".into()), true));
2452 assert_eq!(ed.stops.len(), 2);
2453 }
2454
2455 #[test]
2456 fn build_idle_job_yaml_parks_worker() {
2457 let ed = WorkerRouteEditorState::new("w1".into(), "L".into(), Some("bed-1".into()));
2458 let yaml = ed.build_idle_job_yaml();
2459 assert!(yaml.contains("mode: idle"));
2460 assert!(yaml.contains("steps: []"));
2461 assert!(!yaml.contains("route:"));
2462 }
2463
2464 #[test]
2465 fn remove_selected_stop_adjusts_index() {
2466 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2467 ed.append_waypoint(1.0, 2.0, 0.0);
2468 ed.append_harvest_node("oak-a");
2469 ed.append_deposit_at("chest-1");
2470 ed.select_stop(2);
2471 ed.remove_selected_stop();
2472 assert_eq!(ed.stops.len(), 2);
2473 assert_eq!(ed.selected_stop_index, 1);
2474 }
2475
2476 #[test]
2477 fn build_job_yaml_includes_trade_with_stop() {
2478 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2479 ed.append_trade_with("oak_log".into(), None, true);
2480 ed.append_trade_with("lumber".into(), Some("ada_broker".into()), false);
2481 let yaml = ed.build_job_yaml().expect("yaml");
2482 assert!(yaml.contains("stop: trade_with, template: oak_log, sell_all: true"));
2483 assert!(yaml.contains("npc_id: ada_broker"));
2484 assert!(yaml.contains("sell_all: false"));
2485 }
2486
2487 #[test]
2488 fn build_job_yaml_includes_list_on_market_stop() {
2489 let mut ed = WorkerRouteEditorState::new("w1".into(), "Laborer".into(), None);
2490 let _ = ed.insert_stop(WorkerRouteStop::ListOnMarket {
2491 template: "carrot".into(),
2492 list_all: true,
2493 hall_id: Some("town_market".into()),
2494 });
2495 let yaml = ed.build_job_yaml().expect("yaml");
2496 assert!(yaml.contains("list_on_market"), "{yaml}");
2497 assert!(yaml.contains("template: carrot"), "{yaml}");
2498 assert!(yaml.contains("hall_id: town_market"), "{yaml}");
2499 assert!(yaml.contains("list_all: true"), "{yaml}");
2500 }
2501
2502 #[test]
2503 fn set_selected_trade_npc_updates_stop() {
2504 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2505 ed.append_trade_with("oak_log".into(), None, true);
2506 assert!(ed.set_selected_trade_npc("ada_broker".into()));
2507 assert!(
2508 matches!(&ed.stops[0], WorkerRouteStop::TradeWith { npc_id, .. } if npc_id.as_deref() == Some("ada_broker"))
2509 );
2510 }
2511
2512 #[test]
2513 fn build_job_yaml_deposit_filter_round_trips() {
2514 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), Some("bed-1".into()));
2515 ed.append_deposit_at_filtered("chest-out", vec!["lumber".into()]);
2516 let yaml = ed.build_job_yaml().expect("yaml");
2517 assert!(yaml.contains("stop: deposit_at, container_id: chest-out, filter: [lumber]"));
2518 }
2519
2520 #[test]
2521 fn build_job_yaml_includes_withdraw_and_craft_stops() {
2522 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2523 ed.stops.push(WorkerRouteStop::WithdrawFrom {
2524 container_id: "chest-src".into(),
2525 items: vec![WorkerRouteWithdrawItem {
2526 template: "oak_log".into(),
2527 qty: None,
2528 }],
2529 });
2530 ed.stops.push(WorkerRouteStop::WithdrawFrom {
2531 container_id: "chest-src-2".into(),
2532 items: vec![WorkerRouteWithdrawItem {
2533 template: "iron_ore".into(),
2534 qty: Some(10),
2535 }],
2536 });
2537 ed.stops.push(WorkerRouteStop::CraftAt {
2538 device: "hand".into(),
2539 blueprint: "oak_to_lumber".into(),
2540 qty: None,
2541 });
2542 ed.append_deposit_at("chest-out");
2543 let yaml = ed.build_job_yaml().expect("yaml");
2544 assert!(yaml.contains("stop: withdraw_from"));
2545 assert!(yaml.contains("container_id: chest-src"));
2546 assert!(yaml.contains("template: oak_log, all: true"));
2547 assert!(yaml.contains("template: iron_ore, qty: 10"));
2548 assert!(yaml.contains("stop: craft_at, device: hand, blueprint: oak_to_lumber"));
2549 assert!(yaml.contains("stop: deposit_at"));
2550 }
2551
2552 #[test]
2553 fn withdraw_summary_shows_all_vs_qty() {
2554 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2555 ed.stops.push(WorkerRouteStop::WithdrawFrom {
2556 container_id: "chest-src".into(),
2557 items: vec![WorkerRouteWithdrawItem {
2558 template: "oak_log".into(),
2559 qty: None,
2560 }],
2561 });
2562 assert!(ed.stops[0].summary().contains("withdraw all oak_log"));
2563 }
2564
2565 #[test]
2566 fn withdraw_view_round_trips_all_flag() {
2567 let view = WorkerRouteStopView::WithdrawFrom {
2568 container_id: "chest-1".into(),
2569 items: vec![
2570 flatland_protocol::WorkerWithdrawItemView {
2571 template: "oak_log".into(),
2572 qty: 0,
2573 all: true,
2574 },
2575 flatland_protocol::WorkerWithdrawItemView {
2576 template: "iron_ore".into(),
2577 qty: 5,
2578 all: false,
2579 },
2580 ],
2581 };
2582 let stop = stop_view_to_stop(&view);
2583 let WorkerRouteStop::WithdrawFrom { items, .. } = stop else {
2584 panic!("expected withdraw stop");
2585 };
2586 assert_eq!(items[0].qty, None);
2587 assert_eq!(items[1].qty, Some(5));
2588 }
2589
2590 #[test]
2591 fn legacy_harvest_loop_route_converts_to_ordered_stops() {
2592 let route = WorkerRouteView {
2593 kind: WorkerRouteKindView::HarvestLoop,
2594 lodging_container_id: Some("bed-1".into()),
2595 outbound_waypoints: vec![flatland_protocol::WorkerRouteWaypointView {
2596 x: 1.0,
2597 y: 2.0,
2598 z: 0.0,
2599 }],
2600 harvest_nodes: vec!["oak-1".into()],
2601 carry_return_ratio: 0.9,
2602 stops: Vec::new(),
2603 };
2604 let ed = WorkerRouteEditorState::from_saved_route("w1".into(), "L".into(), &route, None);
2605 assert_eq!(ed.stops.len(), 4);
2607 assert!(
2608 matches!(&ed.stops[2], WorkerRouteStop::DepositAt { container_id, .. } if container_id == "bed-1")
2609 );
2610 assert!(matches!(&ed.stops[3], WorkerRouteStop::RestIfNeeded));
2611 }
2612
2613 #[test]
2616 fn retarget_withdraw_container_updates_editing_stop() {
2617 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2618 ed.stops.push(WorkerRouteStop::WithdrawFrom {
2619 container_id: "chest-old".into(),
2620 items: vec![WorkerRouteWithdrawItem {
2621 template: "iron_ore".into(),
2622 qty: None,
2623 }],
2624 });
2625 ed.selected_stop_index = 0;
2626 ed.editing_index = Some(0);
2627 assert!(ed.retarget_withdraw_container("chest-new".into()));
2628 assert!(matches!(
2629 &ed.stops[0],
2630 WorkerRouteStop::WithdrawFrom { container_id, .. } if container_id == "chest-new"
2631 ));
2632 }
2633
2634 #[test]
2635 fn summary_resolved_uses_friendly_labels() {
2636 let stop = WorkerRouteStop::WithdrawFrom {
2637 container_id: "uuid-iron".into(),
2638 items: vec![WorkerRouteWithdrawItem {
2639 template: "iron_ore".into(),
2640 qty: None,
2641 }],
2642 };
2643 let summary = stop.summary_resolved(
2644 |_| "Iron Ore Container".into(),
2645 |_| "Ada".into(),
2646 |_| "Oak Tree".into(),
2647 |_| "Food Pad".into(),
2648 |t| {
2649 if t == "iron_ore" {
2650 "Iron Ore".into()
2651 } else {
2652 t.to_string()
2653 }
2654 },
2655 );
2656 assert_eq!(summary, "withdraw all Iron Ore from Iron Ore Container");
2657 assert!(!summary.contains("uuid"));
2658 assert!(!summary.contains("iron_ore"));
2659 }
2660
2661 #[test]
2662 fn summary_resolved_deposit_filter_uses_item_labels() {
2663 let stop = WorkerRouteStop::DepositAt {
2664 container_id: "chest-out".into(),
2665 filter: Some(vec!["carrot_seed".into(), "oak_log".into()]),
2666 };
2667 let summary = stop.summary_resolved(
2668 |_| "Food Pantry".into(),
2669 |_| "Ada".into(),
2670 |_| "Oak Tree".into(),
2671 |_| "Food Pad".into(),
2672 |t| match t {
2673 "carrot_seed" => "Carrot Seed".into(),
2674 "oak_log" => "Oak Log".into(),
2675 other => other.to_string(),
2676 },
2677 );
2678 assert_eq!(
2679 summary,
2680 "deposit at Food Pantry only Carrot Seed, Oak Log"
2681 );
2682 assert!(!summary.contains("carrot_seed"));
2683 assert!(!summary.contains("oak_log"));
2684 }
2685
2686 #[test]
2687 fn summary_resolved_uses_plot_labels() {
2688 let plot_id = uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap();
2689 let cultivate = WorkerRouteStop::CultivatePlot { plot_id };
2690 let plant = WorkerRouteStop::PlantPlot {
2691 plot_id,
2692 seed_template: "potato_seed".into(),
2693 };
2694 let harvest = WorkerRouteStop::HarvestPlot { plot_id };
2695 let friendly = |id: &uuid::Uuid| {
2696 assert_eq!(*id, plot_id);
2697 "Madsin — Starter Town East — Food Pad".to_string()
2698 };
2699 let blank = |_: &str| String::new();
2700 let item = |t: &str| {
2701 if t == "potato_seed" {
2702 "Potato Seed".to_string()
2703 } else {
2704 t.to_string()
2705 }
2706 };
2707 assert_eq!(
2708 cultivate.summary_resolved(blank, blank, blank, friendly, item),
2709 "cultivate Madsin — Starter Town East — Food Pad"
2710 );
2711 assert_eq!(
2712 plant.summary_resolved(blank, blank, blank, friendly, item),
2713 "plant Potato Seed on Madsin — Starter Town East — Food Pad"
2714 );
2715 assert_eq!(
2716 harvest.summary_resolved(blank, blank, blank, friendly, item),
2717 "harvest Madsin — Starter Town East — Food Pad"
2718 );
2719 let named = cultivate.summary_resolved(blank, blank, blank, friendly, item);
2720 assert!(
2721 !named.contains("19fe35f"),
2722 "resolved plot summary must not include hex id: {named}"
2723 );
2724 let raw = cultivate.summary();
2725 assert_eq!(raw, "cultivate plot 19fe35f0");
2726 }
2727
2728 #[test]
2729 fn list_filter_row_matches_name_and_distance() {
2730 assert!(list_filter_row_matches(
2731 "oak",
2732 None,
2733 &["Oak Tree", "oak_log"]
2734 ));
2735 assert!(!list_filter_row_matches(
2736 "pine",
2737 None,
2738 &["Oak Tree", "oak_log"]
2739 ));
2740 assert!(list_filter_row_matches(
2741 "oak 50m",
2742 Some(40.0),
2743 &["Oak Tree"]
2744 ));
2745 assert!(!list_filter_row_matches(
2746 "oak 50m",
2747 Some(60.0),
2748 &["Oak Tree"]
2749 ));
2750 assert!(list_filter_row_matches("", Some(999.0), &["anything"]));
2751 }
2752
2753 #[test]
2754 fn node_candidates_sort_from_lodging_anchor_not_player() {
2755 use flatland_protocol::{ResourceNodeState, ResourceNodeView};
2756 fn node(id: &str, label: &str, x: f32) -> ResourceNodeView {
2757 ResourceNodeView {
2758 id: id.into(),
2759 label: label.into(),
2760 x,
2761 y: 0.0,
2762 z: 0.0,
2763 item_template: "oak_log".into(),
2764 state: ResourceNodeState::Available,
2765 blocking: true,
2766 blocking_radius_m: 0.8,
2767 harvest_off: false,
2768 tile_id: None,
2769 yaw: 0.0,
2770 pitch: 0.0,
2771 roll: 0.0,
2772 draw_scale: 1.0,
2773 sprite_mode: None,
2774 presentation_state: None,
2775 growth_progress: None,
2776 channel_start_tick: None,
2777 channel_end_tick: None,
2778 harvest_drop_templates: vec![],
2779 }
2780 }
2781 let nodes = vec![node("far", "Far Oak", 100.0), node("near", "Near Oak", 5.0)];
2782 let sorted = node_candidates(&nodes, 0.0, 0.0);
2783 assert_eq!(sorted[0].id, "near");
2784 assert_eq!(sorted[1].id, "far");
2785 assert!((sorted[0].dist - 5.0).abs() < 0.01);
2786
2787 let stable = node_candidates_stable(&nodes);
2788 assert!(
2789 stable[0].label.starts_with("Far Oak ("),
2790 "got {}",
2791 stable[0].label
2792 );
2793 assert!(
2794 stable[1].label.starts_with("Near Oak ("),
2795 "got {}",
2796 stable[1].label
2797 );
2798 assert!(stable[0].dist.is_nan());
2799 }
2800
2801 #[test]
2802 fn node_candidates_include_depleted_and_skip_preview() {
2803 use flatland_protocol::{ResourceNodeState, ResourceNodeView};
2804 fn node(id: &str, label: &str, state: ResourceNodeState) -> ResourceNodeView {
2805 ResourceNodeView {
2806 id: id.into(),
2807 label: label.into(),
2808 x: 0.0,
2809 y: 0.0,
2810 z: 0.0,
2811 item_template: "oak_log".into(),
2812 state,
2813 blocking: true,
2814 blocking_radius_m: 0.8,
2815 harvest_off: false,
2816 tile_id: None,
2817 yaw: 0.0,
2818 pitch: 0.0,
2819 roll: 0.0,
2820 draw_scale: 1.0,
2821 sprite_mode: None,
2822 presentation_state: None,
2823 growth_progress: None,
2824 channel_start_tick: None,
2825 channel_end_tick: None,
2826 harvest_drop_templates: vec![],
2827 }
2828 }
2829 let nodes = vec![
2830 node("oak-ready", "Ready Oak", ResourceNodeState::Available),
2831 node("oak-down", "Regen Oak", ResourceNodeState::Cooldown),
2832 node(
2833 "preview:neighbor-oak",
2834 "Ghost Oak",
2835 ResourceNodeState::Available,
2836 ),
2837 node(
2838 "carcass-wolf-1",
2839 "Wolf carcass",
2840 ResourceNodeState::Available,
2841 ),
2842 ];
2843 let ids: Vec<_> = node_candidates_stable(&nodes)
2844 .into_iter()
2845 .map(|n| n.id)
2846 .collect();
2847 assert!(ids.contains(&"oak-ready".to_string()));
2848 assert!(ids.contains(&"oak-down".to_string()));
2849 assert!(!ids.iter().any(|id| id.starts_with("preview:")));
2850 assert!(!ids.iter().any(|id| id.starts_with("carcass-")));
2851 assert_eq!(ids.len(), 2);
2852 let depleted = node_candidates_stable(&nodes)
2853 .into_iter()
2854 .find(|n| n.id == "oak-down")
2855 .unwrap();
2856 assert!(
2857 depleted.label.contains("depleted"),
2858 "got {}",
2859 depleted.label
2860 );
2861 }
2862
2863 #[test]
2864 fn sheet_back_walks_up_hierarchy() {
2865 use RouteEditorSheet as S;
2866 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2867 assert_eq!(ed.sheet, S::Stops);
2868 ed.open_add_menu();
2869 assert_eq!(ed.sheet, S::AddMenu { index: 0 });
2870 ed.open_sheet(S::WithdrawContainers { index: 0 });
2871 ed.open_sheet(S::WithdrawItems {
2872 container_id: "c1".into(),
2873 lines: vec![],
2874 index: 0,
2875 });
2876 ed.sheet_back();
2877 assert_eq!(ed.sheet, S::WithdrawContainers { index: 0 });
2878 ed.sheet_back();
2879 assert_eq!(ed.sheet, S::AddMenu { index: 0 });
2880 ed.sheet_back();
2881 assert_eq!(ed.sheet, S::Stops);
2882 ed.sheet_back();
2884 assert_eq!(ed.sheet, S::Stops);
2885 }
2886
2887 #[test]
2888 fn sheet_back_while_editing_returns_to_stops() {
2889 use RouteEditorSheet as S;
2890 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2891 ed.append_harvest_node("oak-a");
2892 ed.begin_edit_selected();
2893 ed.open_sheet(S::HarvestPicker {
2894 index: 0,
2895 picked: BTreeSet::new(),
2896 nodes: Vec::new(),
2897 });
2898 ed.sheet_back();
2899 assert_eq!(ed.sheet, S::Stops);
2900 assert_eq!(ed.editing_index, None);
2901 }
2902
2903 #[test]
2904 fn sheet_back_while_editing_withdraw_keeps_edit_on_container_picker() {
2905 use RouteEditorSheet as S;
2906 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2907 ed.stops.push(WorkerRouteStop::WithdrawFrom {
2908 container_id: "chest-a".into(),
2909 items: vec![WorkerRouteWithdrawItem {
2910 template: "oak_log".into(),
2911 qty: None,
2912 }],
2913 });
2914 ed.begin_edit_selected();
2915 ed.open_sheet(S::WithdrawItems {
2916 container_id: "chest-a".into(),
2917 lines: vec![],
2918 index: 0,
2919 });
2920 ed.sheet_back();
2921 assert!(matches!(ed.sheet, S::WithdrawContainers { .. }));
2922 assert_eq!(
2923 ed.editing_index,
2924 Some(0),
2925 "still editing after back to picker"
2926 );
2927 ed.sheet_back();
2928 assert_eq!(ed.sheet, S::Stops);
2929 assert_eq!(ed.editing_index, None);
2930 }
2931
2932 #[test]
2933 fn confirm_stop_replaces_withdraw_container_when_editing() {
2934 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2935 ed.stops.push(WorkerRouteStop::WithdrawFrom {
2936 container_id: "chest-old".into(),
2937 items: vec![WorkerRouteWithdrawItem {
2938 template: "oak_log".into(),
2939 qty: None,
2940 }],
2941 });
2942 ed.stops.push(WorkerRouteStop::RestIfNeeded);
2943 ed.select_stop(0);
2944 ed.begin_edit_selected();
2945 assert!(ed.confirm_stop(WorkerRouteStop::WithdrawFrom {
2946 container_id: "chest-new".into(),
2947 items: vec![WorkerRouteWithdrawItem {
2948 template: "oak_log".into(),
2949 qty: None,
2950 }],
2951 }));
2952 assert_eq!(ed.stops.len(), 2);
2953 assert!(matches!(
2954 &ed.stops[0],
2955 WorkerRouteStop::WithdrawFrom { container_id, .. } if container_id == "chest-new"
2956 ));
2957 }
2958
2959 #[test]
2960 fn confirm_stop_replaces_when_editing() {
2961 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2962 ed.append_harvest_node("oak-a");
2963 ed.append_waypoint(1.0, 1.0, 0.0);
2964 ed.select_stop(0);
2965 ed.begin_edit_selected();
2966 assert!(ed.confirm_stop(WorkerRouteStop::HarvestNode {
2967 node_id: "oak-b".into()
2968 }));
2969 assert_eq!(ed.stops.len(), 2, "edit replaces in place, no append");
2970 assert!(
2971 matches!(&ed.stops[0], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
2972 );
2973 assert_eq!(ed.sheet, RouteEditorSheet::Stops);
2974 assert_eq!(ed.editing_index, None);
2975 }
2976
2977 #[test]
2978 fn confirm_stop_dedupes_on_append() {
2979 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2980 ed.append_harvest_node("oak-a");
2981 assert!(!ed.confirm_stop(WorkerRouteStop::HarvestNode {
2982 node_id: "oak-a".into()
2983 }));
2984 assert_eq!(ed.stops.len(), 1);
2985 assert_eq!(ed.selected_stop_index, 0);
2986 }
2987
2988 #[test]
2989 fn withdraw_line_cycle_and_collect() {
2990 let contents = vec![
2991 ItemStack {
2992 template_id: "oak_log".into(),
2993 quantity: 12,
2994 ..Default::default()
2995 },
2996 ItemStack {
2997 template_id: "lumber".into(),
2998 quantity: 4,
2999 ..Default::default()
3000 },
3001 ];
3002 let mut lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &[]);
3003 assert_eq!(lines.len(), 2);
3004 lines[1].cycle();
3006 assert_eq!(lines[1].mode, WithdrawLineMode::All);
3007 lines[0].cycle();
3008 lines[0].cycle();
3009 assert!(matches!(lines[0].mode, WithdrawLineMode::Qty(_)));
3010 lines[0].adjust_qty(5);
3011 let items = WorkerRouteEditorState::withdraw_items_from_lines(&lines);
3012 assert_eq!(items.len(), 2);
3013 assert_eq!(items[0].template, "lumber");
3014 assert_eq!(items[0].qty, Some(4));
3016 assert_eq!(items[1].qty, None);
3017 }
3018
3019 #[test]
3020 fn withdraw_drafts_prefill_existing_and_keep_missing() {
3021 let contents = vec![ItemStack {
3022 template_id: "oak_log".into(),
3023 quantity: 3,
3024 ..Default::default()
3025 }];
3026 let existing = vec![
3027 WorkerRouteWithdrawItem {
3028 template: "oak_log".into(),
3029 qty: None,
3030 },
3031 WorkerRouteWithdrawItem {
3032 template: "iron_ore".into(),
3033 qty: Some(5),
3034 },
3035 ];
3036 let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
3037 assert_eq!(lines.len(), 2);
3038 let ore = lines
3039 .iter()
3040 .find(|l| l.template == "iron_ore")
3041 .expect("ore line");
3042 assert_eq!(ore.available, 0, "missing template kept with 0 available");
3043 assert_eq!(ore.mode, WithdrawLineMode::Qty(5));
3044 let oak = lines
3045 .iter()
3046 .find(|l| l.template == "oak_log")
3047 .expect("oak line");
3048 assert_eq!(oak.mode, WithdrawLineMode::All);
3049 }
3050}