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