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 None,
395 )
396}
397
398pub fn owned_container_candidates_with_occupants_and_buildings(
400 placed: &[PlacedContainerView],
401 buildings: &[flatland_protocol::BuildingView],
402 character_id: Option<uuid::Uuid>,
403 px: f32,
404 py: f32,
405 hired: &[flatland_protocol::HiredWorkerView],
406 observer_inside: Option<&str>,
407) -> Vec<ContainerCandidate> {
408 let Some(cid) = character_id else {
409 return Vec::new();
410 };
411 let mut out: Vec<ContainerCandidate> = placed
412 .iter()
413 .filter(|c| c.owner_character_id == Some(cid))
414 .filter(|c| {
415 c.capacity_volume.unwrap_or(0.0) > 0.0 || c.worker_lodging_capacity.unwrap_or(0) > 0
416 })
417 .map(|c| {
418 let is_lodging = c.worker_lodging_capacity.unwrap_or(0) > 0;
419 let mut summary = summarize_contents(&c.contents);
420 if is_lodging {
421 let who = lodging_occupants_for(hired, &c.id);
422 let who = if who.is_empty() {
423 "vacant".into()
424 } else {
425 who.join(", ")
426 };
427 summary = format!("lodged: {who} · {summary}");
428 }
429 let cross_space = !container_in_observer_space(c, observer_inside);
430 let building = c
431 .building_id
432 .as_ref()
433 .and_then(|bid| buildings.iter().find(|b| &b.id == bid));
434 let (rx, ry) = if cross_space {
437 building
438 .map(|b| (b.x + b.width_m * 0.5, b.y + b.depth_m * 0.5))
439 .unwrap_or((c.x, c.y))
440 } else {
441 (c.x, c.y)
442 };
443 let name = if cross_space {
444 match building {
445 Some(b) if !b.label.is_empty() => {
446 format!("{} ({})", c.display_name, b.label)
447 }
448 Some(b) => format!("{} ({})", c.display_name, b.id),
449 None => c.display_name.clone(),
450 }
451 } else {
452 c.display_name.clone()
453 };
454 ContainerCandidate {
455 id: c.id.clone(),
456 name,
457 is_lodging,
458 summary,
459 dist: dist2d(px, py, rx, ry),
460 }
461 })
462 .collect();
463
464 for b in buildings {
465 if !b
466 .tags
467 .iter()
468 .any(|t| t.eq_ignore_ascii_case("storage"))
469 {
470 continue;
471 }
472 let name = if b.label.is_empty() {
473 format!("Town storage ({})", b.id)
474 } else {
475 b.label.clone()
476 };
477 out.push(ContainerCandidate {
478 id: b.id.clone(),
479 name,
480 is_lodging: false,
481 summary: "(town vault)".into(),
482 dist: dist2d(px, py, b.x, b.y),
483 });
484 }
485
486 out.sort_by(|a, b| {
487 a.dist
488 .partial_cmp(&b.dist)
489 .unwrap_or(std::cmp::Ordering::Equal)
490 .then_with(|| a.name.cmp(&b.name))
491 .then_with(|| a.id.cmp(&b.id))
492 });
493 out
494}
495
496fn lodging_occupants_for(
497 hired: &[flatland_protocol::HiredWorkerView],
498 container_id: &str,
499) -> Vec<String> {
500 let mut names: Vec<String> = hired
501 .iter()
502 .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
503 .map(|w| w.label.clone())
504 .collect();
505 names.sort();
506 names
507}
508
509#[derive(Debug, Clone, PartialEq)]
511pub struct NodeCandidate {
512 pub id: String,
513 pub label: String,
514 pub template: String,
515 pub dist: f32,
516}
517
518pub fn node_candidates(
520 nodes: &[ResourceNodeView],
521 anchor_x: f32,
522 anchor_y: f32,
523) -> Vec<NodeCandidate> {
524 let mut out: Vec<NodeCandidate> = nodes
525 .iter()
526 .filter(|n| !n.harvest_off)
527 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
528 .map(|n| NodeCandidate {
529 id: n.id.clone(),
530 label: crate::resource_node_route_label(n),
531 template: n.item_template.clone(),
532 dist: dist2d(anchor_x, anchor_y, n.x, n.y),
533 })
534 .collect();
535 out.sort_by(|a, b| {
536 a.dist
537 .partial_cmp(&b.dist)
538 .unwrap_or(std::cmp::Ordering::Equal)
539 .then_with(|| a.label.cmp(&b.label))
540 .then_with(|| a.id.cmp(&b.id))
541 });
542 out
543}
544
545pub fn node_candidates_stable(nodes: &[ResourceNodeView]) -> Vec<NodeCandidate> {
547 let mut out: Vec<NodeCandidate> = nodes
548 .iter()
549 .filter(|n| !n.harvest_off)
550 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
551 .map(|n| NodeCandidate {
552 id: n.id.clone(),
553 label: crate::resource_node_route_label(n),
554 template: n.item_template.clone(),
555 dist: f32::NAN,
556 })
557 .collect();
558 out.sort_by(|a, b| a.label.cmp(&b.label).then_with(|| a.id.cmp(&b.id)));
559 out
560}
561
562pub fn route_editor_lodging_anchor(
564 lodging_container_id: Option<&str>,
565 placed: &[PlacedContainerView],
566) -> Option<(f32, f32)> {
567 let id = lodging_container_id?;
568 placed
569 .iter()
570 .find(|c| c.id == id)
571 .map(|c| (c.x, c.y))
572}
573
574pub const ROUTE_PICKER_DONE_ROW: usize = 0;
576pub const SELL_ITEM_TOGGLE_ROW: usize = 1;
578
579pub fn harvest_picker_row_count(nodes_len: usize) -> usize {
580 nodes_len + 1
581}
582
583pub fn sell_item_picker_row_count(templates_len: usize) -> usize {
584 templates_len + 2
585}
586
587pub fn harvest_picker_row_matches(nodes: &[NodeCandidate], row: usize, filter: &str) -> bool {
589 if row == ROUTE_PICKER_DONE_ROW {
590 return true;
591 }
592 let slot = row - 1;
593 nodes.get(slot).is_some_and(|n| {
594 let dist = n.dist.is_finite().then_some(n.dist);
595 list_filter_row_matches(filter, dist, &[&n.label, &n.template, &n.id])
596 })
597}
598
599#[derive(Debug, Clone, PartialEq)]
601pub struct TradeNpcCandidate {
602 pub id: String,
603 pub label: String,
604 pub dist: f32,
605}
606
607pub fn trade_npc_candidates(npcs: &[NpcView], px: f32, py: f32) -> Vec<TradeNpcCandidate> {
609 let mut out: Vec<TradeNpcCandidate> = npcs
610 .iter()
611 .filter(|n| n.can_trade)
612 .map(|n| TradeNpcCandidate {
613 id: n.id.clone(),
614 label: n.label.clone(),
615 dist: dist2d(px, py, n.x, n.y),
616 })
617 .collect();
618 out.sort_by(|a, b| {
619 a.dist
620 .partial_cmp(&b.dist)
621 .unwrap_or(std::cmp::Ordering::Equal)
622 .then_with(|| a.id.cmp(&b.id))
623 });
624 out
625}
626
627#[derive(Debug, Clone)]
632pub struct WorkerRouteEditorState {
633 pub worker_instance_id: String,
634 pub worker_label: String,
635 pub lodging_container_id: Option<String>,
636 pub stops: Vec<WorkerRouteStop>,
638 pub selected_stop_index: usize,
640 pub carry_return_ratio: f32,
642 pub sheet: RouteEditorSheet,
644 pub editing_index: Option<usize>,
647 pub panel_collapsed: bool,
649 pub sheet_filter: String,
651 pub sheet_filter_focused: bool,
652}
653
654impl WorkerRouteEditorState {
655 pub fn new(
656 worker_instance_id: String,
657 worker_label: String,
658 lodging_container_id: Option<String>,
659 ) -> Self {
660 Self {
661 worker_instance_id,
662 worker_label,
663 lodging_container_id,
664 stops: Vec::new(),
665 selected_stop_index: 0,
666 carry_return_ratio: 0.90,
667 sheet: RouteEditorSheet::Stops,
668 editing_index: None,
669 panel_collapsed: false,
670 sheet_filter: String::new(),
671 sheet_filter_focused: false,
672 }
673 }
674
675 pub fn toggle_panel_collapsed(&mut self) {
676 self.panel_collapsed = !self.panel_collapsed;
677 }
678
679 pub fn from_saved_route(
680 worker_instance_id: String,
681 worker_label: String,
682 route: &WorkerRouteView,
683 lodging_fallback: Option<String>,
684 ) -> Self {
685 let lodging = route
686 .lodging_container_id
687 .clone()
688 .or(lodging_fallback);
689
690 match route.kind {
691 WorkerRouteKindView::Ordered => Self {
692 worker_instance_id,
693 worker_label,
694 lodging_container_id: lodging,
695 stops: route
696 .stops
697 .iter()
698 .map(stop_view_to_stop)
699 .collect(),
700 selected_stop_index: 0,
701 carry_return_ratio: route.carry_return_ratio,
702 sheet: RouteEditorSheet::Stops,
703 editing_index: None,
704 panel_collapsed: false,
705 sheet_filter: String::new(),
706 sheet_filter_focused: false,
707 },
708 WorkerRouteKindView::HarvestLoop => {
709 let mut stops = Vec::new();
714 for wp in &route.outbound_waypoints {
715 stops.push(WorkerRouteStop::Waypoint {
716 x: wp.x,
717 y: wp.y,
718 z: wp.z,
719 });
720 }
721 for node in &route.harvest_nodes {
722 stops.push(WorkerRouteStop::HarvestNode {
723 node_id: node.clone(),
724 });
725 }
726 if let Some(lodging) = &lodging {
727 stops.push(WorkerRouteStop::DepositAt {
728 container_id: lodging.clone(),
729 filter: None,
730 });
731 stops.push(WorkerRouteStop::RestIfNeeded);
732 }
733 Self {
734 worker_instance_id,
735 worker_label,
736 lodging_container_id: lodging,
737 stops,
738 selected_stop_index: 0,
739 carry_return_ratio: route.carry_return_ratio,
740 sheet: RouteEditorSheet::Stops,
741 editing_index: None,
742 panel_collapsed: false,
743 sheet_filter: String::new(),
744 sheet_filter_focused: false,
745 }
746 }
747 }
748 }
749
750 pub fn stop_count(&self) -> usize {
753 self.stops.len()
754 }
755
756 pub fn select_stop(&mut self, index: usize) {
757 if self.stops.is_empty() {
758 self.selected_stop_index = 0;
759 return;
760 }
761 self.selected_stop_index = index.min(self.stops.len() - 1);
762 }
763
764 pub fn move_selected_up(&mut self) {
765 if self.selected_stop_index == 0 {
766 return;
767 }
768 self.stops
769 .swap(self.selected_stop_index, self.selected_stop_index - 1);
770 self.selected_stop_index -= 1;
771 }
772
773 pub fn move_selected_down(&mut self) {
774 if self.selected_stop_index + 1 >= self.stops.len() {
775 return;
776 }
777 self.stops
778 .swap(self.selected_stop_index, self.selected_stop_index + 1);
779 self.selected_stop_index += 1;
780 }
781
782 pub fn remove_selected_stop(&mut self) {
783 if self.stops.is_empty() {
784 return;
785 }
786 let idx = self.selected_stop_index.min(self.stops.len() - 1);
787 self.stops.remove(idx);
788 self.editing_index = None;
789 if self.selected_stop_index >= self.stops.len() {
790 self.selected_stop_index = self.stops.len().saturating_sub(1);
791 }
792 }
793
794 fn find_stop(&self, pred: impl Fn(&WorkerRouteStop) -> bool) -> Option<usize> {
796 self.stops.iter().position(pred)
797 }
798
799 pub fn harvest_node_index(&self, node_id: &str) -> Option<usize> {
800 self.find_stop(
801 |s| matches!(s, WorkerRouteStop::HarvestNode { node_id: n } if n == node_id),
802 )
803 }
804
805 pub fn deposit_container_index(&self, container_id: &str) -> Option<usize> {
806 self.find_stop(
807 |s| matches!(s, WorkerRouteStop::DepositAt { container_id: c, .. } if c == container_id),
808 )
809 }
810
811 pub fn trade_stop_index(&self, npc_id: Option<&str>, template: &str) -> Option<usize> {
812 self.find_stop(|s| {
813 matches!(s, WorkerRouteStop::TradeWith { npc_id: n, template: t, .. }
814 if n.as_deref() == npc_id && t == template)
815 })
816 }
817
818 pub fn insert_stop(&mut self, stop: WorkerRouteStop) -> (bool, usize) {
822 let existing = match &stop {
823 WorkerRouteStop::HarvestNode { node_id } => self.harvest_node_index(node_id),
824 WorkerRouteStop::DepositAt { container_id, .. } => {
825 self.deposit_container_index(container_id)
826 }
827 WorkerRouteStop::TradeWith { npc_id, template, .. } => {
828 self.trade_stop_index(npc_id.as_deref(), template)
829 }
830 _ => None,
831 };
832 if let Some(idx) = existing {
833 self.selected_stop_index = idx;
834 return (false, idx);
835 }
836 self.stops.push(stop);
837 self.selected_stop_index = self.stops.len() - 1;
838 (true, self.stops.len() - 1)
839 }
840
841 pub fn append_waypoint(&mut self, x: f32, y: f32, z: f32) {
842 self.insert_stop(WorkerRouteStop::Waypoint { x, y, z });
843 }
844
845 pub fn append_harvest_node(&mut self, node_id: &str) -> bool {
846 self.insert_stop(WorkerRouteStop::HarvestNode {
847 node_id: node_id.to_string(),
848 })
849 .0
850 }
851
852 pub fn append_deposit_at(&mut self, container_id: &str) -> bool {
853 self.insert_stop(WorkerRouteStop::DepositAt {
854 container_id: container_id.to_string(),
855 filter: None,
856 })
857 .0
858 }
859
860 pub fn append_deposit_at_filtered(&mut self, container_id: &str, filter_templates: Vec<String>) {
863 self.stops.push(WorkerRouteStop::DepositAt {
864 container_id: container_id.to_string(),
865 filter: Some(filter_templates),
866 });
867 self.selected_stop_index = self.stops.len() - 1;
868 }
869
870 pub fn append_rest_if_needed(&mut self) {
871 self.insert_stop(WorkerRouteStop::RestIfNeeded);
872 }
873
874 pub fn append_wait(&mut self, wait_ticks: u64) {
875 self.insert_stop(WorkerRouteStop::Wait { wait_ticks });
876 }
877
878 pub fn append_trade_with(&mut self, template: String, npc_id: Option<String>, sell_all: bool) -> bool {
882 self.insert_stop(WorkerRouteStop::TradeWith {
883 npc_id,
884 template,
885 sell_all,
886 })
887 .0
888 }
889
890 pub fn set_selected_trade_npc(&mut self, npc_id: String) -> bool {
895 let Some(stop) = self.stops.get_mut(self.selected_stop_index) else {
896 return false;
897 };
898 let WorkerRouteStop::TradeWith { npc_id: slot, template, .. } = stop else {
899 return false;
900 };
901 *slot = Some(npc_id.clone());
902 let template = template.clone();
903 let selected = self.selected_stop_index;
904 if let Some(other) = self
905 .trade_stop_index(Some(npc_id.as_str()), template.as_str())
906 .filter(|&i| i != selected)
907 {
908 self.stops.remove(selected);
909 self.selected_stop_index = if other > selected { other - 1 } else { other };
910 }
911 true
912 }
913
914 pub fn set_selected_withdraw_container(&mut self, container_id: String) -> bool {
918 let Some(stop) = self.stops.get_mut(self.selected_stop_index) else {
919 return false;
920 };
921 if let WorkerRouteStop::WithdrawFrom { container_id: slot, .. } = stop {
922 *slot = container_id;
923 return true;
924 }
925 false
926 }
927
928 pub fn retarget_withdraw_container(&mut self, container_id: String) -> bool {
931 let idx = self.editing_index.unwrap_or(self.selected_stop_index);
932 let Some(stop) = self.stops.get_mut(idx) else {
933 return false;
934 };
935 if let WorkerRouteStop::WithdrawFrom { container_id: slot, .. } = stop {
936 *slot = container_id;
937 return true;
938 }
939 false
940 }
941
942 pub fn retarget_deposit_container(&mut self, container_id: String) -> bool {
945 let idx = self.editing_index.unwrap_or(self.selected_stop_index);
946 let Some(stop) = self.stops.get_mut(idx) else {
947 return false;
948 };
949 if let WorkerRouteStop::DepositAt { container_id: slot, .. } = stop {
950 *slot = container_id;
951 return true;
952 }
953 false
954 }
955
956 pub fn open_add_menu(&mut self) {
959 self.editing_index = None;
960 self.sheet = RouteEditorSheet::AddMenu { index: 0 };
961 }
962
963 pub fn open_sheet(&mut self, sheet: RouteEditorSheet) {
964 self.sheet_filter.clear();
965 self.sheet_filter_focused = false;
966 self.sheet = sheet;
967 }
968
969 pub fn confirm_harvest_picks(&mut self, node_ids: &[String]) -> usize {
971 if node_ids.is_empty() {
972 return 0;
973 }
974 let mut added = 0usize;
975 if let Some(idx) = self.editing_index.take() {
976 if let Some(first) = node_ids.first() {
977 if idx < self.stops.len() {
978 self.stops[idx] = WorkerRouteStop::HarvestNode {
979 node_id: first.clone(),
980 };
981 self.selected_stop_index = idx;
982 added = 1;
983 }
984 for id in node_ids.iter().skip(1) {
985 if self
986 .insert_stop(WorkerRouteStop::HarvestNode {
987 node_id: id.clone(),
988 })
989 .0
990 {
991 added += 1;
992 }
993 }
994 }
995 } else {
996 for id in node_ids {
997 if self
998 .insert_stop(WorkerRouteStop::HarvestNode {
999 node_id: id.clone(),
1000 })
1001 .0
1002 {
1003 added += 1;
1004 }
1005 }
1006 }
1007 self.sheet = RouteEditorSheet::Stops;
1008 added
1009 }
1010
1011 pub fn confirm_trade_picks(
1013 &mut self,
1014 npc_id: Option<String>,
1015 templates: &[String],
1016 sell_all: bool,
1017 ) -> usize {
1018 if templates.is_empty() {
1019 return 0;
1020 }
1021 let mut added = 0usize;
1022 if let Some(idx) = self.editing_index.take() {
1023 if let Some(first) = templates.first() {
1024 if idx < self.stops.len() {
1025 self.stops[idx] = WorkerRouteStop::TradeWith {
1026 npc_id: npc_id.clone(),
1027 template: first.clone(),
1028 sell_all,
1029 };
1030 self.selected_stop_index = idx;
1031 added = 1;
1032 }
1033 for template in templates.iter().skip(1) {
1034 if self
1035 .insert_stop(WorkerRouteStop::TradeWith {
1036 npc_id: npc_id.clone(),
1037 template: template.clone(),
1038 sell_all,
1039 })
1040 .0
1041 {
1042 added += 1;
1043 }
1044 }
1045 }
1046 } else {
1047 for template in templates {
1048 if self
1049 .insert_stop(WorkerRouteStop::TradeWith {
1050 npc_id: npc_id.clone(),
1051 template: template.clone(),
1052 sell_all,
1053 })
1054 .0
1055 {
1056 added += 1;
1057 }
1058 }
1059 }
1060 self.sheet = RouteEditorSheet::Stops;
1061 added
1062 }
1063
1064 pub fn begin_edit_selected(&mut self) {
1067 if self.selected_stop_index < self.stops.len() {
1068 self.editing_index = Some(self.selected_stop_index);
1069 }
1070 }
1071
1072 pub fn sheet_back(&mut self) {
1076 use RouteEditorSheet as S;
1077 let editing = self.editing_index.is_some();
1078 let next = match &self.sheet {
1079 S::Stops => return,
1080 S::AddMenu { .. } | S::BedPicker { .. } | S::FarmPlotPicker { .. } => S::Stops,
1081 S::FarmPlantSeed { .. } => S::FarmPlotPicker {
1082 index: 0,
1083 action: FarmPlotAction::Plant,
1084 },
1085 S::WaypointMapPick => {
1086 if editing {
1087 S::Stops
1088 } else {
1089 S::WaypointMenu { index: 0 }
1090 }
1091 }
1092 S::WithdrawItems { .. } => S::WithdrawContainers { index: 0 },
1095 S::DepositFilter { .. } => S::DepositContainers { index: 0 },
1096 S::SellItem { .. } => S::SellNpcs { index: 0 },
1097 S::WithdrawContainers { .. }
1098 | S::DepositContainers { .. }
1099 | S::SellNpcs { .. }
1100 if editing =>
1101 {
1102 S::Stops
1103 }
1104 _ => {
1106 if editing {
1107 S::Stops
1108 } else {
1109 S::AddMenu { index: 0 }
1110 }
1111 }
1112 };
1113 if matches!(next, S::Stops) {
1114 self.editing_index = None;
1115 }
1116 self.sheet = next;
1117 }
1118
1119 pub fn confirm_stop(&mut self, stop: WorkerRouteStop) -> bool {
1123 let result = if let Some(idx) = self.editing_index.take() {
1124 if idx < self.stops.len() {
1125 self.stops[idx] = stop;
1126 self.selected_stop_index = idx;
1127 }
1128 true
1129 } else {
1130 self.insert_stop(stop).0
1131 };
1132 self.sheet = RouteEditorSheet::Stops;
1133 result
1134 }
1135
1136 pub fn withdraw_line_drafts(
1140 contents: &[ItemStack],
1141 existing: &[WorkerRouteWithdrawItem],
1142 ) -> Vec<WithdrawLineDraft> {
1143 let mut lines: Vec<WithdrawLineDraft> = Vec::new();
1144 for s in contents {
1145 if s.template_id.is_empty() {
1146 continue;
1147 }
1148 match lines.iter_mut().find(|l| l.template == s.template_id) {
1149 Some(l) => l.available += s.quantity,
1150 None => lines.push(WithdrawLineDraft {
1151 template: s.template_id.clone(),
1152 available: s.quantity,
1153 mode: WithdrawLineMode::Off,
1154 }),
1155 }
1156 }
1157 for item in existing {
1158 let mode = match item.qty {
1159 None => WithdrawLineMode::All,
1160 Some(q) => WithdrawLineMode::Qty(q),
1161 };
1162 match lines.iter_mut().find(|l| l.template == item.template) {
1163 Some(l) => l.mode = mode,
1164 None => lines.push(WithdrawLineDraft {
1165 template: item.template.clone(),
1166 available: 0,
1167 mode,
1168 }),
1169 }
1170 }
1171 lines.sort_by(|a, b| a.template.cmp(&b.template));
1172 lines
1173 }
1174
1175 pub fn withdraw_items_from_lines(lines: &[WithdrawLineDraft]) -> Vec<WorkerRouteWithdrawItem> {
1177 lines
1178 .iter()
1179 .filter_map(|l| match l.mode {
1180 WithdrawLineMode::Off => None,
1181 WithdrawLineMode::All => Some(WorkerRouteWithdrawItem {
1182 template: l.template.clone(),
1183 qty: None,
1184 }),
1185 WithdrawLineMode::Qty(q) => Some(WorkerRouteWithdrawItem {
1186 template: l.template.clone(),
1187 qty: Some(q),
1188 }),
1189 })
1190 .collect()
1191 }
1192
1193 fn job_id(&self) -> String {
1196 format!(
1197 "route_{}",
1198 self.worker_instance_id
1199 .chars()
1200 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
1201 .collect::<String>()
1202 )
1203 }
1204
1205 pub fn build_idle_job_yaml(&self) -> String {
1209 let job_id = self.job_id();
1212 [format!("job_id: {job_id}"), "mode: idle".into(), "steps: []".into()].join("\n")
1213 }
1214
1215 pub fn build_job_yaml(&self) -> Result<String, String> {
1216 if self.stops.is_empty() {
1217 return Err("add at least one stop (waypoint, harvest node, or deposit)".into());
1218 }
1219 let job_id = self.job_id();
1220 let mut lines = vec![
1221 format!("job_id: {job_id}"),
1222 "mode: job_loop".into(),
1223 "route:".into(),
1224 " kind: ordered".into(),
1225 ];
1226 if let Some(lodging) = &self.lodging_container_id {
1227 lines.push(format!(" lodging_container_id: {lodging}"));
1228 }
1229 lines.push(format!(
1230 " carry_return_ratio: {:.2}",
1231 self.carry_return_ratio
1232 ));
1233 lines.push(" stops:".into());
1234 for stop in &self.stops {
1235 match stop {
1236 WorkerRouteStop::Waypoint { x, y, z } => {
1237 lines.push(format!(
1238 " - {{ stop: waypoint, x: {:.1}, y: {:.1}, z: {:.1} }}",
1239 x, y, z
1240 ));
1241 }
1242 WorkerRouteStop::HarvestNode { node_id } => {
1243 lines.push(format!(" - {{ stop: harvest_node, node_id: {node_id} }}"));
1244 }
1245 WorkerRouteStop::DepositAt { container_id, filter } => {
1246 let f = filter
1247 .as_ref()
1248 .filter(|f| !f.is_empty())
1249 .map(|f| format!(", filter: [{}]", f.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")))
1250 .unwrap_or_default();
1251 lines.push(format!(
1252 " - {{ stop: deposit_at, container_id: {container_id}{f} }}"
1253 ));
1254 }
1255 WorkerRouteStop::RestIfNeeded => {
1256 lines.push(" - { stop: rest_if_needed }".into());
1257 }
1258 WorkerRouteStop::Wait { wait_ticks } => {
1259 lines.push(format!(" - {{ stop: wait, wait_ticks: {wait_ticks} }}"));
1260 }
1261 WorkerRouteStop::TradeWith {
1262 npc_id,
1263 template,
1264 sell_all,
1265 } => {
1266 let who = npc_id
1267 .as_deref()
1268 .map(|n| format!(", npc_id: {n}"))
1269 .unwrap_or_default();
1270 lines.push(format!(
1271 " - {{ stop: trade_with, template: {template}{who}, sell_all: {sell_all} }}"
1272 ));
1273 }
1274 WorkerRouteStop::WithdrawFrom { container_id, items } => {
1275 let mut block = format!(
1276 " - stop: withdraw_from\n container_id: {container_id}\n items:"
1277 );
1278 for it in items {
1279 let line = match it.qty {
1280 None => format!("\n - {{ template: {}, all: true }}", it.template),
1281 Some(q) => format!("\n - {{ template: {}, qty: {} }}", it.template, q),
1282 };
1283 block.push_str(&line);
1284 }
1285 lines.push(block);
1286 }
1287 WorkerRouteStop::CraftAt {
1288 device,
1289 blueprint,
1290 qty,
1291 } => {
1292 let qty_str = qty
1293 .map(|q| format!(", qty: {q}"))
1294 .unwrap_or_default();
1295 lines.push(format!(
1296 " - {{ stop: craft_at, device: {device}, blueprint: {blueprint}{qty_str} }}"
1297 ));
1298 }
1299 WorkerRouteStop::CultivatePlot { plot_id } => {
1300 lines.push(format!(
1301 " - {{ stop: cultivate_plot, plot_id: \"{plot_id}\" }}"
1302 ));
1303 }
1304 WorkerRouteStop::PlantPlot {
1305 plot_id,
1306 seed_template,
1307 } => {
1308 lines.push(format!(
1309 " - {{ stop: plant_plot, plot_id: \"{plot_id}\", seed_template: {seed_template} }}"
1310 ));
1311 }
1312 WorkerRouteStop::HarvestPlot { plot_id } => {
1313 lines.push(format!(
1314 " - {{ stop: harvest_plot, plot_id: \"{plot_id}\" }}"
1315 ));
1316 }
1317 }
1318 }
1319 lines.push("steps: []".into());
1320 Ok(lines.join("\n"))
1321 }
1322
1323 pub fn to_route_view(&self) -> flatland_protocol::WorkerRouteView {
1325 use flatland_protocol::{
1326 WorkerRouteKindView, WorkerRouteStopView, WorkerRouteView, WorkerWithdrawItemView,
1327 };
1328 WorkerRouteView {
1329 kind: WorkerRouteKindView::Ordered,
1330 lodging_container_id: self.lodging_container_id.clone(),
1331 outbound_waypoints: Vec::new(),
1332 harvest_nodes: Vec::new(),
1333 carry_return_ratio: self.carry_return_ratio,
1334 stops: self
1335 .stops
1336 .iter()
1337 .map(|stop| match stop {
1338 WorkerRouteStop::Waypoint { x, y, z } => WorkerRouteStopView::Waypoint {
1339 x: *x,
1340 y: *y,
1341 z: *z,
1342 },
1343 WorkerRouteStop::HarvestNode { node_id } => WorkerRouteStopView::HarvestNode {
1344 node_id: node_id.clone(),
1345 },
1346 WorkerRouteStop::DepositAt { container_id, filter } => {
1347 WorkerRouteStopView::DepositAt {
1348 container_id: container_id.clone(),
1349 filter: filter.clone(),
1350 }
1351 }
1352 WorkerRouteStop::TradeWith {
1353 npc_id,
1354 template,
1355 sell_all,
1356 } => WorkerRouteStopView::TradeWith {
1357 npc_id: npc_id.clone(),
1358 template: template.clone(),
1359 sell_all: *sell_all,
1360 },
1361 WorkerRouteStop::WithdrawFrom { container_id, items } => {
1362 WorkerRouteStopView::WithdrawFrom {
1363 container_id: container_id.clone(),
1364 items: items
1365 .iter()
1366 .map(|i| WorkerWithdrawItemView {
1367 template: i.template.clone(),
1368 qty: i.qty.unwrap_or(0),
1369 all: i.qty.is_none(),
1370 })
1371 .collect(),
1372 }
1373 }
1374 WorkerRouteStop::CraftAt {
1375 device,
1376 blueprint,
1377 qty,
1378 } => WorkerRouteStopView::CraftAt {
1379 device: device.clone(),
1380 blueprint: blueprint.clone(),
1381 qty: *qty,
1382 },
1383 WorkerRouteStop::CultivatePlot { plot_id } => {
1384 WorkerRouteStopView::CultivatePlot {
1385 plot_id: *plot_id,
1386 }
1387 }
1388 WorkerRouteStop::PlantPlot {
1389 plot_id,
1390 seed_template,
1391 } => WorkerRouteStopView::PlantPlot {
1392 plot_id: *plot_id,
1393 seed_template: seed_template.clone(),
1394 },
1395 WorkerRouteStop::HarvestPlot { plot_id } => WorkerRouteStopView::HarvestPlot {
1396 plot_id: *plot_id,
1397 },
1398 WorkerRouteStop::RestIfNeeded => WorkerRouteStopView::RestIfNeeded,
1399 WorkerRouteStop::Wait { wait_ticks } => WorkerRouteStopView::Wait {
1400 wait_ticks: *wait_ticks,
1401 },
1402 })
1403 .collect(),
1404 }
1405 }
1406}
1407
1408fn stop_view_to_stop(view: &WorkerRouteStopView) -> WorkerRouteStop {
1409 match view {
1410 WorkerRouteStopView::Waypoint { x, y, z } => WorkerRouteStop::Waypoint {
1411 x: *x,
1412 y: *y,
1413 z: *z,
1414 },
1415 WorkerRouteStopView::HarvestNode { node_id } => WorkerRouteStop::HarvestNode {
1416 node_id: node_id.clone(),
1417 },
1418 WorkerRouteStopView::DepositAt { container_id, filter } => WorkerRouteStop::DepositAt {
1419 container_id: container_id.clone(),
1420 filter: filter.clone(),
1421 },
1422 WorkerRouteStopView::TradeWith {
1423 npc_id,
1424 template,
1425 sell_all,
1426 } => WorkerRouteStop::TradeWith {
1427 npc_id: npc_id.clone(),
1428 template: template.clone(),
1429 sell_all: *sell_all,
1430 },
1431 WorkerRouteStopView::WithdrawFrom { container_id, items } => WorkerRouteStop::WithdrawFrom {
1432 container_id: container_id.clone(),
1433 items: items
1434 .iter()
1435 .map(|i| WorkerRouteWithdrawItem {
1436 template: i.template.clone(),
1437 qty: if i.all { None } else { Some(i.qty) },
1438 })
1439 .collect(),
1440 },
1441 WorkerRouteStopView::CraftAt {
1442 device,
1443 blueprint,
1444 qty,
1445 } => WorkerRouteStop::CraftAt {
1446 device: device.clone(),
1447 blueprint: blueprint.clone(),
1448 qty: *qty,
1449 },
1450 WorkerRouteStopView::CultivatePlot { plot_id } => WorkerRouteStop::CultivatePlot {
1451 plot_id: *plot_id,
1452 },
1453 WorkerRouteStopView::PlantPlot {
1454 plot_id,
1455 seed_template,
1456 } => WorkerRouteStop::PlantPlot {
1457 plot_id: *plot_id,
1458 seed_template: seed_template.clone(),
1459 },
1460 WorkerRouteStopView::HarvestPlot { plot_id } => WorkerRouteStop::HarvestPlot {
1461 plot_id: *plot_id,
1462 },
1463 WorkerRouteStopView::RestIfNeeded => WorkerRouteStop::RestIfNeeded,
1464 WorkerRouteStopView::Wait { wait_ticks } => WorkerRouteStop::Wait {
1465 wait_ticks: *wait_ticks,
1466 },
1467 }
1468}
1469
1470const HARVEST_NODE_PICK_M: f32 = 4.0;
1473const LODGING_PICK_M: f32 = 5.0;
1474const STORAGE_PICK_M: f32 = 5.0;
1475
1476fn dist2d(x0: f32, y0: f32, x1: f32, y1: f32) -> f32 {
1477 let dx = x0 - x1;
1478 let dy = y0 - y1;
1479 (dx * dx + dy * dy).sqrt()
1480}
1481
1482pub fn pick_resource_node_at<'a>(
1484 nodes: &'a [ResourceNodeView],
1485 x: f32,
1486 y: f32,
1487) -> Option<&'a ResourceNodeView> {
1488 nodes
1489 .iter()
1490 .filter(|n| !n.harvest_off)
1491 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
1492 .filter_map(|n| {
1493 let d = dist2d(x, y, n.x, n.y);
1494 if d <= HARVEST_NODE_PICK_M {
1495 Some((d, n))
1496 } else {
1497 None
1498 }
1499 })
1500 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1501 .map(|(_, n)| n)
1502}
1503
1504pub fn owned_lodging_container_ids(
1506 placed: &[PlacedContainerView],
1507 character_id: Option<uuid::Uuid>,
1508) -> Vec<(String, String)> {
1509 owned_lodging_container_ids_with_occupants(placed, character_id, &[])
1510}
1511
1512pub fn owned_lodging_container_ids_with_occupants(
1514 placed: &[PlacedContainerView],
1515 character_id: Option<uuid::Uuid>,
1516 hired: &[flatland_protocol::HiredWorkerView],
1517) -> Vec<(String, String)> {
1518 let Some(cid) = character_id else {
1519 return Vec::new();
1520 };
1521 let mut out: Vec<(String, String)> = placed
1522 .iter()
1523 .filter(|c| c.worker_lodging_capacity.unwrap_or(0) > 0)
1524 .filter(|c| c.owner_character_id == Some(cid))
1525 .map(|c| {
1526 let who = lodging_occupants_for(hired, &c.id);
1527 let name = if who.is_empty() {
1528 format!("{} — vacant", c.display_name)
1529 } else {
1530 format!("{} — {}", c.display_name, who.join(", "))
1531 };
1532 (c.id.clone(), name)
1533 })
1534 .collect();
1535 out.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
1536 out
1537}
1538
1539pub fn pick_lodging_container_at(
1540 placed: &[PlacedContainerView],
1541 character_id: Option<uuid::Uuid>,
1542 x: f32,
1543 y: f32,
1544 observer_inside: Option<&str>,
1545) -> Option<String> {
1546 let cid = character_id?;
1547 placed
1548 .iter()
1549 .filter(|c| container_in_observer_space(c, observer_inside))
1550 .filter(|c| c.worker_lodging_capacity.unwrap_or(0) > 0)
1551 .filter(|c| c.owner_character_id == Some(cid))
1552 .filter_map(|c| {
1553 let d = dist2d(x, y, c.x, c.y);
1554 if d <= LODGING_PICK_M {
1555 Some((d, c.id.clone()))
1556 } else {
1557 None
1558 }
1559 })
1560 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1561 .map(|(_, id)| id)
1562}
1563
1564pub fn pick_trade_npc_at(
1566 npcs: &[NpcView],
1567 x: f32,
1568 y: f32,
1569) -> Option<(String, String)> {
1570 const NPC_PICK_M: f32 = 5.0;
1571 npcs.iter()
1572 .filter(|n| n.can_trade)
1573 .filter_map(|n| {
1574 let d = dist2d(x, y, n.x, n.y);
1575 if d <= NPC_PICK_M {
1576 Some((d, n.id.clone(), n.label.clone()))
1577 } else {
1578 None
1579 }
1580 })
1581 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1582 .map(|(_, id, label)| (id, label))
1583}
1584
1585pub fn owned_storage_template_ids(
1588 placed: &[PlacedContainerView],
1589 character_id: Option<uuid::Uuid>,
1590) -> Vec<String> {
1591 let Some(cid) = character_id else {
1592 return Vec::new();
1593 };
1594 let mut out: Vec<String> = placed
1595 .iter()
1596 .filter(|c| c.owner_character_id == Some(cid))
1597 .flat_map(|c| c.contents.iter().map(|s| s.template_id.clone()))
1598 .filter(|t| !t.is_empty())
1599 .collect();
1600 out.sort();
1601 out.dedup();
1602 out
1603}
1604
1605pub fn worker_craft_blueprint_ids(
1612 blueprints: &[flatland_protocol::BlueprintView],
1613 known_blueprint_ids: Option<&[String]>,
1614) -> Vec<String> {
1615 let mut ids: Vec<String> = blueprints.iter().map(|b| b.id.clone()).collect();
1616 if let Some(known) = known_blueprint_ids {
1617 if !known.is_empty() {
1618 ids.retain(|id| known.iter().any(|k| k == id));
1619 }
1620 }
1621 ids
1622}
1623
1624pub fn route_item_template_candidates(
1629 placed: &[PlacedContainerView],
1630 character_id: Option<uuid::Uuid>,
1631 inventory: &std::collections::HashMap<String, u32>,
1632 blueprints: &[flatland_protocol::BlueprintView],
1633 resource_nodes: &[flatland_protocol::ResourceNodeView],
1634 extra: &[String],
1635) -> Vec<String> {
1636 let mut out = owned_storage_template_ids(placed, character_id);
1637 for (template, qty) in inventory {
1638 if *qty > 0 && !template.is_empty() {
1639 out.push(template.clone());
1640 }
1641 }
1642 for bp in blueprints {
1643 if !bp.output.is_empty() {
1644 out.push(bp.output.clone());
1645 }
1646 for input in &bp.inputs {
1647 if !input.template_id.is_empty() {
1648 out.push(input.template_id.clone());
1649 }
1650 }
1651 for tool in &bp.required_tools {
1652 if !tool.item.is_empty() {
1653 out.push(tool.item.clone());
1654 }
1655 }
1656 }
1657 for node in resource_nodes {
1658 if !node.item_template.is_empty() {
1659 out.push(node.item_template.clone());
1660 }
1661 for t in &node.harvest_drop_templates {
1662 if !t.is_empty() {
1663 out.push(t.clone());
1664 }
1665 }
1666 }
1667 for t in extra {
1668 if !t.is_empty() {
1669 out.push(t.clone());
1670 }
1671 }
1672 out.sort();
1673 out.dedup();
1674 out
1675}
1676
1677pub fn pick_storage_container_at(
1681 placed: &[PlacedContainerView],
1682 character_id: Option<uuid::Uuid>,
1683 x: f32,
1684 y: f32,
1685 observer_inside: Option<&str>,
1686) -> Option<String> {
1687 let cid = character_id?;
1688 placed
1689 .iter()
1690 .filter(|c| container_in_observer_space(c, observer_inside))
1691 .filter(|c| c.owner_character_id == Some(cid))
1692 .filter(|c| c.capacity_volume.unwrap_or(0.0) > 0.0)
1693 .filter_map(|c| {
1694 let d = dist2d(x, y, c.x, c.y);
1695 if d <= STORAGE_PICK_M {
1696 Some((d, c.id.clone()))
1697 } else {
1698 None
1699 }
1700 })
1701 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1702 .map(|(_, id)| id)
1703}
1704
1705fn container_in_observer_space(
1706 c: &PlacedContainerView,
1707 observer_inside: Option<&str>,
1708) -> bool {
1709 match (observer_inside, c.building_id.as_deref()) {
1710 (None, None) => true,
1711 (Some(a), Some(b)) => a == b,
1712 _ => false,
1713 }
1714}
1715
1716#[cfg(test)]
1717mod tests {
1718 use super::*;
1719
1720 #[test]
1721 fn worker_craft_blueprint_ids_filters_to_known_recipes() {
1722 use flatland_protocol::{BlueprintIngredientView, BlueprintView};
1723
1724 fn bp(id: &str) -> BlueprintView {
1725 BlueprintView {
1726 id: id.into(),
1727 label: id.into(),
1728 output: "x".into(),
1729 output_qty: 1,
1730 craft_ticks: 1,
1731 inputs: vec![BlueprintIngredientView {
1732 template_id: "oak_log".into(),
1733 quantity: 1,
1734 consumed: true,
1735 display_name: "Oak Log".into(),
1736 }],
1737 station: Some("hand".into()),
1738 category: None,
1739 required_tools: vec![],
1740 skill: None,
1741 failure_chance: 0.0,
1742 worker_train_copper: 0,
1743 output_display_name: "X".into(),
1744 }
1745 }
1746
1747 let all = vec![
1748 bp("oak_to_lumber"),
1749 bp("vegetable_soup"),
1750 bp("craft_simple_camp_bed"),
1751 bp("craft_wooden_chest_small"),
1752 bp("iron_ingot"),
1753 ];
1754 let known = vec![
1755 "oak_to_lumber".into(),
1756 "craft_simple_camp_bed".into(),
1757 "craft_wooden_chest_small".into(),
1758 ];
1759
1760 let filtered = worker_craft_blueprint_ids(&all, Some(&known));
1761 assert_eq!(
1762 filtered,
1763 vec![
1764 "oak_to_lumber",
1765 "craft_simple_camp_bed",
1766 "craft_wooden_chest_small"
1767 ]
1768 );
1769
1770 assert_eq!(
1772 worker_craft_blueprint_ids(&all, Some(&[])).len(),
1773 all.len()
1774 );
1775 assert_eq!(worker_craft_blueprint_ids(&all, None).len(), all.len());
1776 }
1777
1778 #[test]
1779 fn route_item_candidates_include_craft_outputs_not_in_storage() {
1780 use flatland_protocol::{
1781 BlueprintIngredientView, BlueprintView, PlacedContainerView, ResourceNodeView,
1782 ResourceNodeState,
1783 };
1784 use std::collections::HashMap;
1785 use uuid::Uuid;
1786
1787 let cid = Uuid::from_u128(0x1111_2222_3333_4444_5555_6666_7777_8888);
1788 let placed = vec![PlacedContainerView {
1789 id: "chest-1".into(),
1790 template_id: "wooden_chest_small".into(),
1791 display_name: "Chest".into(),
1792 x: 0.0,
1793 y: 0.0,
1794 z: 0.0,
1795 locked: false,
1796 accessible: true,
1797 owner_character_id: Some(cid),
1798 contents: vec![],
1799 lock_id: None,
1800 capacity_volume: Some(20.0),
1801 item_instance_id: None,
1802 tile_id: None,
1803 worker_lodging_capacity: None,
1804 blocking: false,
1805 blocking_radius_m: 0.0,
1806 building_id: None,
1807 }];
1808 let blueprints = vec![BlueprintView {
1809 id: "smelt_iron".into(),
1810 label: "Smelt Iron".into(),
1811 output: "iron_ingot".into(),
1812 output_qty: 1,
1813 craft_ticks: 30,
1814 inputs: vec![BlueprintIngredientView {
1815 template_id: "iron_ore".into(),
1816 quantity: 1,
1817 consumed: true,
1818 display_name: "Iron Ore".into(),
1819 }],
1820 station: Some("hand".into()),
1821 category: None,
1822 required_tools: vec![],
1823 skill: None,
1824 failure_chance: 0.0,
1825 worker_train_copper: 0,
1826 output_display_name: "Iron Ingot".into(),
1827 }];
1828 let nodes = vec![ResourceNodeView {
1829 id: "ore-1".into(),
1830 label: "Iron Ore".into(),
1831 x: 1.0,
1832 y: 1.0,
1833 z: 0.0,
1834 item_template: "iron_ore".into(),
1835 state: ResourceNodeState::Available,
1836 blocking: true,
1837 blocking_radius_m: 0.8,
1838 harvest_off: false,
1839 tile_id: None,
1840 yaw: 0.0,
1841 pitch: 0.0,
1842 roll: 0.0,
1843 draw_scale: 1.0,
1844 sprite_mode: None,
1845 growth_progress: None,
1846 presentation_state: None,
1847 channel_start_tick: None,
1848 channel_end_tick: None,
1849 harvest_drop_templates: vec![],
1850 }];
1851 let ids = route_item_template_candidates(
1852 &placed,
1853 Some(cid),
1854 &HashMap::new(),
1855 &blueprints,
1856 &nodes,
1857 &[],
1858 );
1859 assert!(
1860 ids.contains(&"iron_ingot".to_string()),
1861 "craft output should be selectable before any exists in storage: {ids:?}"
1862 );
1863 assert!(ids.contains(&"iron_ore".to_string()));
1864 }
1865
1866 #[test]
1867 fn route_item_template_includes_harvest_loot_table_drops() {
1868 use flatland_protocol::{ResourceNodeState, ResourceNodeView};
1869 use std::collections::HashMap;
1870 let nodes = vec![ResourceNodeView {
1871 id: "crop-carrot-1".into(),
1872 label: "Wild carrots".into(),
1873 x: 1.0,
1874 y: 1.0,
1875 z: 0.0,
1876 item_template: "carrot_wild".into(),
1877 state: ResourceNodeState::Available,
1878 blocking: false,
1879 blocking_radius_m: 0.8,
1880 harvest_off: false,
1881 tile_id: None,
1882 yaw: 0.0,
1883 pitch: 0.0,
1884 roll: 0.0,
1885 draw_scale: 1.0,
1886 sprite_mode: None,
1887 growth_progress: None,
1888 presentation_state: None,
1889 channel_start_tick: None,
1890 channel_end_tick: None,
1891 harvest_drop_templates: vec!["carrot".into(), "carrot_seed".into()],
1892 }];
1893 let ids = route_item_template_candidates(
1894 &[],
1895 None,
1896 &HashMap::new(),
1897 &[],
1898 &nodes,
1899 &[],
1900 );
1901 assert!(
1902 ids.contains(&"carrot_seed".to_string()),
1903 "deposit filter should list seeds from harvest loot tables: {ids:?}"
1904 );
1905 assert!(ids.contains(&"carrot_wild".to_string()));
1906 }
1907
1908 #[test]
1909 fn build_ordered_job_yaml_includes_stops() {
1910 let mut ed = WorkerRouteEditorState::new(
1911 "worker-worker_laborer-1".into(),
1912 "Laborer".into(),
1913 Some("chest-bed".into()),
1914 );
1915 ed.append_waypoint(10.0, 20.0, 0.0);
1916 ed.append_harvest_node("oak-n1");
1917 ed.append_deposit_at("chest-storage-a");
1918 ed.append_rest_if_needed();
1919 let yaml = ed.build_job_yaml().expect("yaml");
1920 assert!(yaml.contains("kind: ordered"));
1921 assert!(yaml.contains("lodging_container_id: chest-bed"));
1922 assert!(yaml.contains("stop: waypoint"));
1923 assert!(yaml.contains("oak-n1"));
1924 assert!(yaml.contains("deposit_at"));
1925 assert!(yaml.contains("chest-storage-a"));
1926 assert!(yaml.contains("rest_if_needed"));
1927 }
1928
1929 #[test]
1930 fn requires_at_least_one_stop() {
1931 let ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1932 assert!(ed.build_job_yaml().is_err());
1933 }
1934
1935 #[test]
1936 fn reorder_stops() {
1937 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1938 ed.append_harvest_node("oak-a");
1939 ed.append_harvest_node("oak-b");
1940 ed.append_waypoint(5.0, 6.0, 0.0);
1941 ed.select_stop(1);
1943 ed.move_selected_up();
1944 assert!(
1945 matches!(&ed.stops[0], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
1946 );
1947 ed.move_selected_down();
1949 assert!(
1950 matches!(&ed.stops[1], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
1951 );
1952 }
1953
1954 #[test]
1955 fn duplicate_harvest_node_selects_existing_instead() {
1956 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1957 assert!(ed.append_harvest_node("oak-a"));
1958 ed.append_waypoint(1.0, 2.0, 0.0);
1959 assert!(!ed.append_harvest_node("oak-a"));
1960 assert_eq!(ed.stops.len(), 2);
1961 assert_eq!(ed.selected_stop_index, 0);
1962 }
1963
1964 #[test]
1965 fn duplicate_deposit_container_selects_existing_instead() {
1966 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1967 assert!(ed.append_deposit_at("chest-1"));
1968 ed.append_harvest_node("oak-a");
1969 assert!(!ed.append_deposit_at("chest-1"));
1970 assert_eq!(ed.stops.len(), 2);
1971 assert_eq!(ed.selected_stop_index, 0);
1972 }
1973
1974 #[test]
1975 fn duplicate_trade_stop_selects_existing_instead() {
1976 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1977 assert!(ed.append_trade_with("oak_log".into(), Some("ada".into()), true));
1978 assert!(!ed.append_trade_with("oak_log".into(), Some("ada".into()), true));
1979 assert!(ed.append_trade_with("lumber".into(), Some("ada".into()), true));
1981 assert_eq!(ed.stops.len(), 2);
1982 }
1983
1984 #[test]
1985 fn build_idle_job_yaml_parks_worker() {
1986 let ed = WorkerRouteEditorState::new("w1".into(), "L".into(), Some("bed-1".into()));
1987 let yaml = ed.build_idle_job_yaml();
1988 assert!(yaml.contains("mode: idle"));
1989 assert!(yaml.contains("steps: []"));
1990 assert!(!yaml.contains("route:"));
1991 }
1992
1993 #[test]
1994 fn remove_selected_stop_adjusts_index() {
1995 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1996 ed.append_waypoint(1.0, 2.0, 0.0);
1997 ed.append_harvest_node("oak-a");
1998 ed.append_deposit_at("chest-1");
1999 ed.select_stop(2);
2000 ed.remove_selected_stop();
2001 assert_eq!(ed.stops.len(), 2);
2002 assert_eq!(ed.selected_stop_index, 1);
2003 }
2004
2005 #[test]
2006 fn build_job_yaml_includes_trade_with_stop() {
2007 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2008 ed.append_trade_with("oak_log".into(), None, true);
2009 ed.append_trade_with("lumber".into(), Some("ada_broker".into()), false);
2010 let yaml = ed.build_job_yaml().expect("yaml");
2011 assert!(yaml.contains("stop: trade_with, template: oak_log, sell_all: true"));
2012 assert!(yaml.contains("npc_id: ada_broker"));
2013 assert!(yaml.contains("sell_all: false"));
2014 }
2015
2016 #[test]
2017 fn set_selected_trade_npc_updates_stop() {
2018 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2019 ed.append_trade_with("oak_log".into(), None, true);
2020 assert!(ed.set_selected_trade_npc("ada_broker".into()));
2021 assert!(matches!(&ed.stops[0], WorkerRouteStop::TradeWith { npc_id, .. } if npc_id.as_deref() == Some("ada_broker")));
2022 }
2023
2024 #[test]
2025 fn build_job_yaml_deposit_filter_round_trips() {
2026 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), Some("bed-1".into()));
2027 ed.append_deposit_at_filtered("chest-out", vec!["lumber".into()]);
2028 let yaml = ed.build_job_yaml().expect("yaml");
2029 assert!(yaml.contains("stop: deposit_at, container_id: chest-out, filter: [lumber]"));
2030 }
2031
2032 #[test]
2033 fn build_job_yaml_includes_withdraw_and_craft_stops() {
2034 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2035 ed.stops.push(WorkerRouteStop::WithdrawFrom {
2036 container_id: "chest-src".into(),
2037 items: vec![WorkerRouteWithdrawItem { template: "oak_log".into(), qty: None }],
2038 });
2039 ed.stops.push(WorkerRouteStop::WithdrawFrom {
2040 container_id: "chest-src-2".into(),
2041 items: vec![WorkerRouteWithdrawItem { template: "iron_ore".into(), qty: Some(10) }],
2042 });
2043 ed.stops.push(WorkerRouteStop::CraftAt {
2044 device: "hand".into(),
2045 blueprint: "oak_to_lumber".into(),
2046 qty: None,
2047 });
2048 ed.append_deposit_at("chest-out");
2049 let yaml = ed.build_job_yaml().expect("yaml");
2050 assert!(yaml.contains("stop: withdraw_from"));
2051 assert!(yaml.contains("container_id: chest-src"));
2052 assert!(yaml.contains("template: oak_log, all: true"));
2053 assert!(yaml.contains("template: iron_ore, qty: 10"));
2054 assert!(yaml.contains("stop: craft_at, device: hand, blueprint: oak_to_lumber"));
2055 assert!(yaml.contains("stop: deposit_at"));
2056 }
2057
2058 #[test]
2059 fn withdraw_summary_shows_all_vs_qty() {
2060 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2061 ed.stops.push(WorkerRouteStop::WithdrawFrom {
2062 container_id: "chest-src".into(),
2063 items: vec![WorkerRouteWithdrawItem { template: "oak_log".into(), qty: None }],
2064 });
2065 assert!(ed.stops[0].summary().contains("withdraw all oak_log"));
2066 }
2067
2068 #[test]
2069 fn withdraw_view_round_trips_all_flag() {
2070 let view = WorkerRouteStopView::WithdrawFrom {
2071 container_id: "chest-1".into(),
2072 items: vec![
2073 flatland_protocol::WorkerWithdrawItemView {
2074 template: "oak_log".into(),
2075 qty: 0,
2076 all: true,
2077 },
2078 flatland_protocol::WorkerWithdrawItemView {
2079 template: "iron_ore".into(),
2080 qty: 5,
2081 all: false,
2082 },
2083 ],
2084 };
2085 let stop = stop_view_to_stop(&view);
2086 let WorkerRouteStop::WithdrawFrom { items, .. } = stop else {
2087 panic!("expected withdraw stop");
2088 };
2089 assert_eq!(items[0].qty, None);
2090 assert_eq!(items[1].qty, Some(5));
2091 }
2092
2093 #[test]
2094 fn legacy_harvest_loop_route_converts_to_ordered_stops() {
2095 let route = WorkerRouteView {
2096 kind: WorkerRouteKindView::HarvestLoop,
2097 lodging_container_id: Some("bed-1".into()),
2098 outbound_waypoints: vec![flatland_protocol::WorkerRouteWaypointView {
2099 x: 1.0,
2100 y: 2.0,
2101 z: 0.0,
2102 }],
2103 harvest_nodes: vec!["oak-1".into()],
2104 carry_return_ratio: 0.9,
2105 stops: Vec::new(),
2106 };
2107 let ed = WorkerRouteEditorState::from_saved_route(
2108 "w1".into(),
2109 "L".into(),
2110 &route,
2111 None,
2112 );
2113 assert_eq!(ed.stops.len(), 4);
2115 assert!(matches!(&ed.stops[2], WorkerRouteStop::DepositAt { container_id, .. } if container_id == "bed-1"));
2116 assert!(matches!(&ed.stops[3], WorkerRouteStop::RestIfNeeded));
2117 }
2118
2119 #[test]
2122 fn retarget_withdraw_container_updates_editing_stop() {
2123 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2124 ed.stops.push(WorkerRouteStop::WithdrawFrom {
2125 container_id: "chest-old".into(),
2126 items: vec![WorkerRouteWithdrawItem {
2127 template: "iron_ore".into(),
2128 qty: None,
2129 }],
2130 });
2131 ed.selected_stop_index = 0;
2132 ed.editing_index = Some(0);
2133 assert!(ed.retarget_withdraw_container("chest-new".into()));
2134 assert!(matches!(
2135 &ed.stops[0],
2136 WorkerRouteStop::WithdrawFrom { container_id, .. } if container_id == "chest-new"
2137 ));
2138 }
2139
2140 #[test]
2141 fn summary_resolved_uses_friendly_labels() {
2142 let stop = WorkerRouteStop::WithdrawFrom {
2143 container_id: "uuid-iron".into(),
2144 items: vec![WorkerRouteWithdrawItem {
2145 template: "iron_ore".into(),
2146 qty: None,
2147 }],
2148 };
2149 let summary = stop.summary_resolved(
2150 |_| "Iron Ore Container".into(),
2151 |_| "Ada".into(),
2152 |_| "Oak Tree".into(),
2153 );
2154 assert_eq!(summary, "withdraw all iron_ore from Iron Ore Container");
2155 assert!(!summary.contains("uuid"));
2156 }
2157
2158 #[test]
2159 fn list_filter_row_matches_name_and_distance() {
2160 assert!(list_filter_row_matches("oak", None, &["Oak Tree", "oak_log"]));
2161 assert!(!list_filter_row_matches("pine", None, &["Oak Tree", "oak_log"]));
2162 assert!(list_filter_row_matches("oak 50m", Some(40.0), &["Oak Tree"]));
2163 assert!(!list_filter_row_matches("oak 50m", Some(60.0), &["Oak Tree"]));
2164 assert!(list_filter_row_matches("", Some(999.0), &["anything"]));
2165 }
2166
2167 #[test]
2168 fn node_candidates_sort_from_lodging_anchor_not_player() {
2169 use flatland_protocol::{ResourceNodeState, ResourceNodeView};
2170 fn node(id: &str, label: &str, x: f32) -> ResourceNodeView {
2171 ResourceNodeView {
2172 id: id.into(),
2173 label: label.into(),
2174 x,
2175 y: 0.0,
2176 z: 0.0,
2177 item_template: "oak_log".into(),
2178 state: ResourceNodeState::Available,
2179 blocking: true,
2180 blocking_radius_m: 0.8,
2181 harvest_off: false,
2182 tile_id: None,
2183 yaw: 0.0,
2184 pitch: 0.0,
2185 roll: 0.0,
2186 draw_scale: 1.0,
2187 sprite_mode: None,
2188 presentation_state: None,
2189 growth_progress: None,
2190 channel_start_tick: None,
2191 channel_end_tick: None,
2192 harvest_drop_templates: vec![],
2193 }
2194 }
2195 let nodes = vec![node("far", "Far Oak", 100.0), node("near", "Near Oak", 5.0)];
2196 let sorted = node_candidates(&nodes, 0.0, 0.0);
2197 assert_eq!(sorted[0].id, "near");
2198 assert_eq!(sorted[1].id, "far");
2199 assert!((sorted[0].dist - 5.0).abs() < 0.01);
2200
2201 let stable = node_candidates_stable(&nodes);
2202 assert!(
2203 stable[0].label.starts_with("Far Oak ("),
2204 "got {}",
2205 stable[0].label
2206 );
2207 assert!(
2208 stable[1].label.starts_with("Near Oak ("),
2209 "got {}",
2210 stable[1].label
2211 );
2212 assert!(stable[0].dist.is_nan());
2213 }
2214
2215 #[test]
2216 fn sheet_back_walks_up_hierarchy() {
2217 use RouteEditorSheet as S;
2218 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2219 assert_eq!(ed.sheet, S::Stops);
2220 ed.open_add_menu();
2221 assert_eq!(ed.sheet, S::AddMenu { index: 0 });
2222 ed.open_sheet(S::WithdrawContainers { index: 0 });
2223 ed.open_sheet(S::WithdrawItems {
2224 container_id: "c1".into(),
2225 lines: vec![],
2226 index: 0,
2227 });
2228 ed.sheet_back();
2229 assert_eq!(ed.sheet, S::WithdrawContainers { index: 0 });
2230 ed.sheet_back();
2231 assert_eq!(ed.sheet, S::AddMenu { index: 0 });
2232 ed.sheet_back();
2233 assert_eq!(ed.sheet, S::Stops);
2234 ed.sheet_back();
2236 assert_eq!(ed.sheet, S::Stops);
2237 }
2238
2239 #[test]
2240 fn sheet_back_while_editing_returns_to_stops() {
2241 use RouteEditorSheet as S;
2242 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2243 ed.append_harvest_node("oak-a");
2244 ed.begin_edit_selected();
2245 ed.open_sheet(S::HarvestPicker {
2246 index: 0,
2247 picked: BTreeSet::new(),
2248 nodes: Vec::new(),
2249 });
2250 ed.sheet_back();
2251 assert_eq!(ed.sheet, S::Stops);
2252 assert_eq!(ed.editing_index, None);
2253 }
2254
2255 #[test]
2256 fn sheet_back_while_editing_withdraw_keeps_edit_on_container_picker() {
2257 use RouteEditorSheet as S;
2258 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2259 ed.stops.push(WorkerRouteStop::WithdrawFrom {
2260 container_id: "chest-a".into(),
2261 items: vec![WorkerRouteWithdrawItem {
2262 template: "oak_log".into(),
2263 qty: None,
2264 }],
2265 });
2266 ed.begin_edit_selected();
2267 ed.open_sheet(S::WithdrawItems {
2268 container_id: "chest-a".into(),
2269 lines: vec![],
2270 index: 0,
2271 });
2272 ed.sheet_back();
2273 assert!(matches!(ed.sheet, S::WithdrawContainers { .. }));
2274 assert_eq!(ed.editing_index, Some(0), "still editing after back to picker");
2275 ed.sheet_back();
2276 assert_eq!(ed.sheet, S::Stops);
2277 assert_eq!(ed.editing_index, None);
2278 }
2279
2280 #[test]
2281 fn confirm_stop_replaces_withdraw_container_when_editing() {
2282 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2283 ed.stops.push(WorkerRouteStop::WithdrawFrom {
2284 container_id: "chest-old".into(),
2285 items: vec![WorkerRouteWithdrawItem {
2286 template: "oak_log".into(),
2287 qty: None,
2288 }],
2289 });
2290 ed.stops.push(WorkerRouteStop::RestIfNeeded);
2291 ed.select_stop(0);
2292 ed.begin_edit_selected();
2293 assert!(ed.confirm_stop(WorkerRouteStop::WithdrawFrom {
2294 container_id: "chest-new".into(),
2295 items: vec![WorkerRouteWithdrawItem {
2296 template: "oak_log".into(),
2297 qty: None,
2298 }],
2299 }));
2300 assert_eq!(ed.stops.len(), 2);
2301 assert!(matches!(
2302 &ed.stops[0],
2303 WorkerRouteStop::WithdrawFrom { container_id, .. } if container_id == "chest-new"
2304 ));
2305 }
2306
2307 #[test]
2308 fn confirm_stop_replaces_when_editing() {
2309 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2310 ed.append_harvest_node("oak-a");
2311 ed.append_waypoint(1.0, 1.0, 0.0);
2312 ed.select_stop(0);
2313 ed.begin_edit_selected();
2314 assert!(ed.confirm_stop(WorkerRouteStop::HarvestNode {
2315 node_id: "oak-b".into()
2316 }));
2317 assert_eq!(ed.stops.len(), 2, "edit replaces in place, no append");
2318 assert!(
2319 matches!(&ed.stops[0], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
2320 );
2321 assert_eq!(ed.sheet, RouteEditorSheet::Stops);
2322 assert_eq!(ed.editing_index, None);
2323 }
2324
2325 #[test]
2326 fn confirm_stop_dedupes_on_append() {
2327 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2328 ed.append_harvest_node("oak-a");
2329 assert!(!ed.confirm_stop(WorkerRouteStop::HarvestNode {
2330 node_id: "oak-a".into()
2331 }));
2332 assert_eq!(ed.stops.len(), 1);
2333 assert_eq!(ed.selected_stop_index, 0);
2334 }
2335
2336 #[test]
2337 fn withdraw_line_cycle_and_collect() {
2338 let contents = vec![
2339 ItemStack {
2340 template_id: "oak_log".into(),
2341 quantity: 12,
2342 ..Default::default()
2343 },
2344 ItemStack {
2345 template_id: "lumber".into(),
2346 quantity: 4,
2347 ..Default::default()
2348 },
2349 ];
2350 let mut lines =
2351 WorkerRouteEditorState::withdraw_line_drafts(&contents, &[]);
2352 assert_eq!(lines.len(), 2);
2353 lines[1].cycle();
2355 assert_eq!(lines[1].mode, WithdrawLineMode::All);
2356 lines[0].cycle();
2357 lines[0].cycle();
2358 assert!(matches!(lines[0].mode, WithdrawLineMode::Qty(_)));
2359 lines[0].adjust_qty(5);
2360 let items = WorkerRouteEditorState::withdraw_items_from_lines(&lines);
2361 assert_eq!(items.len(), 2);
2362 assert_eq!(items[0].template, "lumber");
2363 assert_eq!(items[0].qty, Some(4));
2365 assert_eq!(items[1].qty, None);
2366 }
2367
2368 #[test]
2369 fn withdraw_drafts_prefill_existing_and_keep_missing() {
2370 let contents = vec![ItemStack {
2371 template_id: "oak_log".into(),
2372 quantity: 3,
2373 ..Default::default()
2374 }];
2375 let existing = vec![
2376 WorkerRouteWithdrawItem { template: "oak_log".into(), qty: None },
2377 WorkerRouteWithdrawItem { template: "iron_ore".into(), qty: Some(5) },
2378 ];
2379 let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
2380 assert_eq!(lines.len(), 2);
2381 let ore = lines.iter().find(|l| l.template == "iron_ore").expect("ore line");
2382 assert_eq!(ore.available, 0, "missing template kept with 0 available");
2383 assert_eq!(ore.mode, WithdrawLineMode::Qty(5));
2384 let oak = lines.iter().find(|l| l.template == "oak_log").expect("oak line");
2385 assert_eq!(oak.mode, WithdrawLineMode::All);
2386 }
2387}