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