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
561#[derive(Debug, Clone, PartialEq)]
563pub struct NodeCandidate {
564 pub id: String,
565 pub label: String,
566 pub template: String,
567 pub dist: f32,
568}
569
570pub fn node_candidates(
572 nodes: &[ResourceNodeView],
573 anchor_x: f32,
574 anchor_y: f32,
575) -> Vec<NodeCandidate> {
576 let mut out: Vec<NodeCandidate> = nodes
577 .iter()
578 .filter(|n| !n.harvest_off)
579 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
580 .map(|n| NodeCandidate {
581 id: n.id.clone(),
582 label: crate::resource_node_route_label(n),
583 template: n.item_template.clone(),
584 dist: dist2d(anchor_x, anchor_y, n.x, n.y),
585 })
586 .collect();
587 out.sort_by(|a, b| {
588 a.dist
589 .partial_cmp(&b.dist)
590 .unwrap_or(std::cmp::Ordering::Equal)
591 .then_with(|| a.label.cmp(&b.label))
592 .then_with(|| a.id.cmp(&b.id))
593 });
594 out
595}
596
597pub fn node_candidates_stable(nodes: &[ResourceNodeView]) -> Vec<NodeCandidate> {
599 let mut out: Vec<NodeCandidate> = nodes
600 .iter()
601 .filter(|n| !n.harvest_off)
602 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
603 .map(|n| NodeCandidate {
604 id: n.id.clone(),
605 label: crate::resource_node_route_label(n),
606 template: n.item_template.clone(),
607 dist: f32::NAN,
608 })
609 .collect();
610 out.sort_by(|a, b| a.label.cmp(&b.label).then_with(|| a.id.cmp(&b.id)));
611 out
612}
613
614pub fn route_editor_lodging_anchor(
616 lodging_container_id: Option<&str>,
617 placed: &[PlacedContainerView],
618) -> Option<(f32, f32)> {
619 let id = lodging_container_id?;
620 placed.iter().find(|c| c.id == id).map(|c| (c.x, c.y))
621}
622
623pub const ROUTE_PICKER_DONE_ROW: usize = 0;
625pub const SELL_ITEM_TOGGLE_ROW: usize = 1;
627
628pub fn harvest_picker_row_count(nodes_len: usize) -> usize {
629 nodes_len + 1
630}
631
632pub fn sell_item_picker_row_count(templates_len: usize) -> usize {
633 templates_len + 2
634}
635
636pub fn harvest_picker_row_matches(nodes: &[NodeCandidate], row: usize, filter: &str) -> bool {
638 if row == ROUTE_PICKER_DONE_ROW {
639 return true;
640 }
641 let slot = row - 1;
642 nodes.get(slot).is_some_and(|n| {
643 let dist = n.dist.is_finite().then_some(n.dist);
644 list_filter_row_matches(filter, dist, &[&n.label, &n.template, &n.id])
645 })
646}
647
648#[derive(Debug, Clone, PartialEq)]
650pub struct TradeNpcCandidate {
651 pub id: String,
652 pub label: String,
653 pub dist: f32,
654 pub buys_route_item: bool,
656}
657
658pub fn merchant_buys_any_route_item(npc: &NpcView, route_templates: &[String]) -> bool {
660 if route_templates.is_empty() {
661 return false;
662 }
663 route_templates
664 .iter()
665 .any(|t| npc.buy_templates.iter().any(|b| b == t))
666}
667
668pub fn any_trade_npc_buys_route_item(npcs: &[NpcView], route_templates: &[String]) -> bool {
670 npcs.iter()
671 .filter(|n| n.can_trade)
672 .any(|n| merchant_buys_any_route_item(n, route_templates))
673}
674
675pub fn sell_merchant_empty_reason(
677 npc_id: Option<&str>,
678 npcs: &[NpcView],
679 route_templates: &[String],
680) -> String {
681 let items = if route_templates.is_empty() {
682 "your route items".to_string()
683 } else {
684 route_templates.join("/")
685 };
686 match npc_id {
687 Some(id) => {
688 let Some(npc) = npcs.iter().find(|n| n.id == id) else {
689 return format!("Route: merchant {id} not found");
690 };
691 let name = if npc.label.is_empty() {
692 npc.id.as_str()
693 } else {
694 npc.label.as_str()
695 };
696 if npc.buy_templates.is_empty() {
697 format!("Route: {name} has an empty buy list — they don't buy any items")
698 } else {
699 format!("Route: {name} doesn't buy any of your route items ({items})")
700 }
701 }
702 None => {
703 if !any_trade_npc_buys_route_item(npcs, route_templates) {
704 format!(
705 "Route: no trade NPC buys any of your route items ({items})"
706 )
707 } else {
708 format!("Route: no sellable item templates for nearest buyer ({items})")
709 }
710 }
711 }
712}
713
714pub fn trade_npc_candidates(
719 npcs: &[NpcView],
720 px: f32,
721 py: f32,
722 route_templates: &[String],
723) -> Vec<TradeNpcCandidate> {
724 let mut out: Vec<TradeNpcCandidate> = npcs
725 .iter()
726 .filter(|n| n.can_trade)
727 .map(|n| TradeNpcCandidate {
728 id: n.id.clone(),
729 label: n.label.clone(),
730 dist: dist2d(px, py, n.x, n.y),
731 buys_route_item: merchant_buys_any_route_item(n, route_templates),
732 })
733 .collect();
734 out.sort_by(|a, b| {
735 b.buys_route_item
737 .cmp(&a.buys_route_item)
738 .then_with(|| {
739 a.dist
740 .partial_cmp(&b.dist)
741 .unwrap_or(std::cmp::Ordering::Equal)
742 })
743 .then_with(|| a.id.cmp(&b.id))
744 });
745 out
746}
747
748#[derive(Debug, Clone)]
753pub struct WorkerRouteEditorState {
754 pub worker_instance_id: String,
755 pub worker_label: String,
756 pub lodging_container_id: Option<String>,
757 pub stops: Vec<WorkerRouteStop>,
759 pub selected_stop_index: usize,
761 pub carry_return_ratio: f32,
763 pub sheet: RouteEditorSheet,
765 pub editing_index: Option<usize>,
768 pub panel_collapsed: bool,
770 pub sheet_filter: String,
772 pub sheet_filter_focused: bool,
773}
774
775impl WorkerRouteEditorState {
776 pub fn new(
777 worker_instance_id: String,
778 worker_label: String,
779 lodging_container_id: Option<String>,
780 ) -> Self {
781 Self {
782 worker_instance_id,
783 worker_label,
784 lodging_container_id,
785 stops: Vec::new(),
786 selected_stop_index: 0,
787 carry_return_ratio: 0.90,
788 sheet: RouteEditorSheet::Stops,
789 editing_index: None,
790 panel_collapsed: false,
791 sheet_filter: String::new(),
792 sheet_filter_focused: false,
793 }
794 }
795
796 pub fn toggle_panel_collapsed(&mut self) {
797 self.panel_collapsed = !self.panel_collapsed;
798 }
799
800 pub fn from_saved_route(
801 worker_instance_id: String,
802 worker_label: String,
803 route: &WorkerRouteView,
804 lodging_fallback: Option<String>,
805 ) -> Self {
806 let lodging = route.lodging_container_id.clone().or(lodging_fallback);
807
808 match route.kind {
809 WorkerRouteKindView::Ordered => Self {
810 worker_instance_id,
811 worker_label,
812 lodging_container_id: lodging,
813 stops: route.stops.iter().map(stop_view_to_stop).collect(),
814 selected_stop_index: 0,
815 carry_return_ratio: route.carry_return_ratio,
816 sheet: RouteEditorSheet::Stops,
817 editing_index: None,
818 panel_collapsed: false,
819 sheet_filter: String::new(),
820 sheet_filter_focused: false,
821 },
822 WorkerRouteKindView::HarvestLoop => {
823 let mut stops = Vec::new();
828 for wp in &route.outbound_waypoints {
829 stops.push(WorkerRouteStop::Waypoint {
830 x: wp.x,
831 y: wp.y,
832 z: wp.z,
833 });
834 }
835 for node in &route.harvest_nodes {
836 stops.push(WorkerRouteStop::HarvestNode {
837 node_id: node.clone(),
838 });
839 }
840 if let Some(lodging) = &lodging {
841 stops.push(WorkerRouteStop::DepositAt {
842 container_id: lodging.clone(),
843 filter: None,
844 });
845 stops.push(WorkerRouteStop::RestIfNeeded);
846 }
847 Self {
848 worker_instance_id,
849 worker_label,
850 lodging_container_id: lodging,
851 stops,
852 selected_stop_index: 0,
853 carry_return_ratio: route.carry_return_ratio,
854 sheet: RouteEditorSheet::Stops,
855 editing_index: None,
856 panel_collapsed: false,
857 sheet_filter: String::new(),
858 sheet_filter_focused: false,
859 }
860 }
861 }
862 }
863
864 pub fn stop_count(&self) -> usize {
867 self.stops.len()
868 }
869
870 pub fn select_stop(&mut self, index: usize) {
871 if self.stops.is_empty() {
872 self.selected_stop_index = 0;
873 return;
874 }
875 self.selected_stop_index = index.min(self.stops.len() - 1);
876 }
877
878 pub fn move_selected_up(&mut self) {
879 if self.selected_stop_index == 0 {
880 return;
881 }
882 self.stops
883 .swap(self.selected_stop_index, self.selected_stop_index - 1);
884 self.selected_stop_index -= 1;
885 }
886
887 pub fn move_selected_down(&mut self) {
888 if self.selected_stop_index + 1 >= self.stops.len() {
889 return;
890 }
891 self.stops
892 .swap(self.selected_stop_index, self.selected_stop_index + 1);
893 self.selected_stop_index += 1;
894 }
895
896 pub fn remove_selected_stop(&mut self) {
897 if self.stops.is_empty() {
898 return;
899 }
900 let idx = self.selected_stop_index.min(self.stops.len() - 1);
901 self.stops.remove(idx);
902 self.editing_index = None;
903 if self.selected_stop_index >= self.stops.len() {
904 self.selected_stop_index = self.stops.len().saturating_sub(1);
905 }
906 }
907
908 fn find_stop(&self, pred: impl Fn(&WorkerRouteStop) -> bool) -> Option<usize> {
910 self.stops.iter().position(pred)
911 }
912
913 pub fn harvest_node_index(&self, node_id: &str) -> Option<usize> {
914 self.find_stop(|s| matches!(s, WorkerRouteStop::HarvestNode { node_id: n } if n == node_id))
915 }
916
917 pub fn deposit_container_index(&self, container_id: &str) -> Option<usize> {
918 self.find_stop(
919 |s| matches!(s, WorkerRouteStop::DepositAt { container_id: c, .. } if c == container_id),
920 )
921 }
922
923 pub fn trade_stop_index(&self, npc_id: Option<&str>, template: &str) -> Option<usize> {
924 self.find_stop(|s| {
925 matches!(s, WorkerRouteStop::TradeWith { npc_id: n, template: t, .. }
926 if n.as_deref() == npc_id && t == template)
927 })
928 }
929
930 pub fn insert_stop(&mut self, stop: WorkerRouteStop) -> (bool, usize) {
934 let existing = match &stop {
935 WorkerRouteStop::HarvestNode { node_id } => self.harvest_node_index(node_id),
936 WorkerRouteStop::DepositAt { container_id, .. } => {
937 self.deposit_container_index(container_id)
938 }
939 WorkerRouteStop::TradeWith {
940 npc_id, template, ..
941 } => self.trade_stop_index(npc_id.as_deref(), template),
942 _ => None,
943 };
944 if let Some(idx) = existing {
945 self.selected_stop_index = idx;
946 return (false, idx);
947 }
948 self.stops.push(stop);
949 self.selected_stop_index = self.stops.len() - 1;
950 (true, self.stops.len() - 1)
951 }
952
953 pub fn append_waypoint(&mut self, x: f32, y: f32, z: f32) {
954 self.insert_stop(WorkerRouteStop::Waypoint { x, y, z });
955 }
956
957 pub fn append_harvest_node(&mut self, node_id: &str) -> bool {
958 self.insert_stop(WorkerRouteStop::HarvestNode {
959 node_id: node_id.to_string(),
960 })
961 .0
962 }
963
964 pub fn append_deposit_at(&mut self, container_id: &str) -> bool {
965 self.insert_stop(WorkerRouteStop::DepositAt {
966 container_id: container_id.to_string(),
967 filter: None,
968 })
969 .0
970 }
971
972 pub fn append_deposit_at_filtered(
975 &mut self,
976 container_id: &str,
977 filter_templates: Vec<String>,
978 ) {
979 self.stops.push(WorkerRouteStop::DepositAt {
980 container_id: container_id.to_string(),
981 filter: Some(filter_templates),
982 });
983 self.selected_stop_index = self.stops.len() - 1;
984 }
985
986 pub fn append_rest_if_needed(&mut self) {
987 self.insert_stop(WorkerRouteStop::RestIfNeeded);
988 }
989
990 pub fn append_wait(&mut self, wait_ticks: u64) {
991 self.insert_stop(WorkerRouteStop::Wait { wait_ticks });
992 }
993
994 pub fn append_trade_with(
998 &mut self,
999 template: String,
1000 npc_id: Option<String>,
1001 sell_all: bool,
1002 ) -> bool {
1003 self.insert_stop(WorkerRouteStop::TradeWith {
1004 npc_id,
1005 template,
1006 sell_all,
1007 })
1008 .0
1009 }
1010
1011 pub fn set_selected_trade_npc(&mut self, npc_id: String) -> bool {
1016 let Some(stop) = self.stops.get_mut(self.selected_stop_index) else {
1017 return false;
1018 };
1019 let WorkerRouteStop::TradeWith {
1020 npc_id: slot,
1021 template,
1022 ..
1023 } = stop
1024 else {
1025 return false;
1026 };
1027 *slot = Some(npc_id.clone());
1028 let template = template.clone();
1029 let selected = self.selected_stop_index;
1030 if let Some(other) = self
1031 .trade_stop_index(Some(npc_id.as_str()), template.as_str())
1032 .filter(|&i| i != selected)
1033 {
1034 self.stops.remove(selected);
1035 self.selected_stop_index = if other > selected { other - 1 } else { other };
1036 }
1037 true
1038 }
1039
1040 pub fn set_selected_withdraw_container(&mut self, container_id: String) -> bool {
1044 let Some(stop) = self.stops.get_mut(self.selected_stop_index) else {
1045 return false;
1046 };
1047 if let WorkerRouteStop::WithdrawFrom {
1048 container_id: slot, ..
1049 } = stop
1050 {
1051 *slot = container_id;
1052 return true;
1053 }
1054 false
1055 }
1056
1057 pub fn retarget_withdraw_container(&mut self, container_id: String) -> bool {
1060 let idx = self.editing_index.unwrap_or(self.selected_stop_index);
1061 let Some(stop) = self.stops.get_mut(idx) 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_deposit_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::DepositAt {
1082 container_id: slot, ..
1083 } = stop
1084 {
1085 *slot = container_id;
1086 return true;
1087 }
1088 false
1089 }
1090
1091 pub fn open_add_menu(&mut self) {
1094 self.editing_index = None;
1095 self.sheet = RouteEditorSheet::AddMenu { index: 0 };
1096 }
1097
1098 pub fn open_sheet(&mut self, sheet: RouteEditorSheet) {
1099 self.sheet_filter.clear();
1100 self.sheet_filter_focused = false;
1101 self.sheet = sheet;
1102 }
1103
1104 pub fn confirm_harvest_picks(&mut self, node_ids: &[String]) -> usize {
1106 if node_ids.is_empty() {
1107 return 0;
1108 }
1109 let mut added = 0usize;
1110 if let Some(idx) = self.editing_index.take() {
1111 if let Some(first) = node_ids.first() {
1112 if idx < self.stops.len() {
1113 self.stops[idx] = WorkerRouteStop::HarvestNode {
1114 node_id: first.clone(),
1115 };
1116 self.selected_stop_index = idx;
1117 added = 1;
1118 }
1119 for id in node_ids.iter().skip(1) {
1120 if self
1121 .insert_stop(WorkerRouteStop::HarvestNode {
1122 node_id: id.clone(),
1123 })
1124 .0
1125 {
1126 added += 1;
1127 }
1128 }
1129 }
1130 } else {
1131 for id in node_ids {
1132 if self
1133 .insert_stop(WorkerRouteStop::HarvestNode {
1134 node_id: id.clone(),
1135 })
1136 .0
1137 {
1138 added += 1;
1139 }
1140 }
1141 }
1142 self.sheet = RouteEditorSheet::Stops;
1143 added
1144 }
1145
1146 pub fn confirm_trade_picks(
1148 &mut self,
1149 npc_id: Option<String>,
1150 templates: &[String],
1151 sell_all: bool,
1152 ) -> usize {
1153 if templates.is_empty() {
1154 return 0;
1155 }
1156 let mut added = 0usize;
1157 if let Some(idx) = self.editing_index.take() {
1158 if let Some(first) = templates.first() {
1159 if idx < self.stops.len() {
1160 self.stops[idx] = WorkerRouteStop::TradeWith {
1161 npc_id: npc_id.clone(),
1162 template: first.clone(),
1163 sell_all,
1164 };
1165 self.selected_stop_index = idx;
1166 added = 1;
1167 }
1168 for template in templates.iter().skip(1) {
1169 if self
1170 .insert_stop(WorkerRouteStop::TradeWith {
1171 npc_id: npc_id.clone(),
1172 template: template.clone(),
1173 sell_all,
1174 })
1175 .0
1176 {
1177 added += 1;
1178 }
1179 }
1180 }
1181 } else {
1182 for template in templates {
1183 if self
1184 .insert_stop(WorkerRouteStop::TradeWith {
1185 npc_id: npc_id.clone(),
1186 template: template.clone(),
1187 sell_all,
1188 })
1189 .0
1190 {
1191 added += 1;
1192 }
1193 }
1194 }
1195 self.sheet = RouteEditorSheet::Stops;
1196 added
1197 }
1198
1199 pub fn confirm_market_list_picks(
1201 &mut self,
1202 hall_id: Option<String>,
1203 templates: &[String],
1204 list_all: bool,
1205 ) -> usize {
1206 if templates.is_empty() {
1207 return 0;
1208 }
1209 let mut added = 0usize;
1210 if let Some(idx) = self.editing_index.take() {
1211 if let Some(first) = templates.first() {
1212 if idx < self.stops.len() {
1213 self.stops[idx] = WorkerRouteStop::ListOnMarket {
1214 template: first.clone(),
1215 list_all,
1216 hall_id: hall_id.clone(),
1217 };
1218 self.selected_stop_index = idx;
1219 added = 1;
1220 }
1221 for template in templates.iter().skip(1) {
1222 if self
1223 .insert_stop(WorkerRouteStop::ListOnMarket {
1224 template: template.clone(),
1225 list_all,
1226 hall_id: hall_id.clone(),
1227 })
1228 .0
1229 {
1230 added += 1;
1231 }
1232 }
1233 }
1234 } else {
1235 for template in templates {
1236 if self
1237 .insert_stop(WorkerRouteStop::ListOnMarket {
1238 template: template.clone(),
1239 list_all,
1240 hall_id: hall_id.clone(),
1241 })
1242 .0
1243 {
1244 added += 1;
1245 }
1246 }
1247 }
1248 self.sheet = RouteEditorSheet::Stops;
1249 added
1250 }
1251
1252 pub fn begin_edit_selected(&mut self) {
1255 if self.selected_stop_index < self.stops.len() {
1256 self.editing_index = Some(self.selected_stop_index);
1257 }
1258 }
1259
1260 pub fn sheet_back(&mut self) {
1264 use RouteEditorSheet as S;
1265 let editing = self.editing_index.is_some();
1266 let next = match &self.sheet {
1267 S::Stops => return,
1268 S::AddMenu { .. } | S::BedPicker { .. } | S::FarmPlotPicker { .. } => S::Stops,
1269 S::FarmPlantSeed { .. } => S::FarmPlotPicker {
1270 index: 0,
1271 action: FarmPlotAction::Plant,
1272 },
1273 S::WaypointMapPick => {
1274 if editing {
1275 S::Stops
1276 } else {
1277 S::WaypointMenu { index: 0 }
1278 }
1279 }
1280 S::WithdrawItems { .. } => S::WithdrawContainers { index: 0 },
1283 S::DepositFilter { .. } => S::DepositContainers { index: 0 },
1284 S::SellItem { .. } => S::SellNpcs { index: 0 },
1285 S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
1286 if editing =>
1287 {
1288 S::Stops
1289 }
1290 _ => {
1292 if editing {
1293 S::Stops
1294 } else {
1295 S::AddMenu { index: 0 }
1296 }
1297 }
1298 };
1299 if matches!(next, S::Stops) {
1300 self.editing_index = None;
1301 }
1302 self.sheet = next;
1303 }
1304
1305 pub fn confirm_stop(&mut self, stop: WorkerRouteStop) -> bool {
1309 let result = if let Some(idx) = self.editing_index.take() {
1310 if idx < self.stops.len() {
1311 self.stops[idx] = stop;
1312 self.selected_stop_index = idx;
1313 }
1314 true
1315 } else {
1316 self.insert_stop(stop).0
1317 };
1318 self.sheet = RouteEditorSheet::Stops;
1319 result
1320 }
1321
1322 pub fn withdraw_line_drafts(
1326 contents: &[ItemStack],
1327 existing: &[WorkerRouteWithdrawItem],
1328 ) -> Vec<WithdrawLineDraft> {
1329 let mut lines: Vec<WithdrawLineDraft> = Vec::new();
1330 for s in contents {
1331 if s.template_id.is_empty() {
1332 continue;
1333 }
1334 match lines.iter_mut().find(|l| l.template == s.template_id) {
1335 Some(l) => l.available += s.quantity,
1336 None => lines.push(WithdrawLineDraft {
1337 template: s.template_id.clone(),
1338 available: s.quantity,
1339 mode: WithdrawLineMode::Off,
1340 }),
1341 }
1342 }
1343 for item in existing {
1344 let mode = match item.qty {
1345 None => WithdrawLineMode::All,
1346 Some(q) => WithdrawLineMode::Qty(q),
1347 };
1348 match lines.iter_mut().find(|l| l.template == item.template) {
1349 Some(l) => l.mode = mode,
1350 None => lines.push(WithdrawLineDraft {
1351 template: item.template.clone(),
1352 available: 0,
1353 mode,
1354 }),
1355 }
1356 }
1357 lines.sort_by(|a, b| a.template.cmp(&b.template));
1358 lines
1359 }
1360
1361 pub fn withdraw_items_from_lines(lines: &[WithdrawLineDraft]) -> Vec<WorkerRouteWithdrawItem> {
1363 lines
1364 .iter()
1365 .filter_map(|l| match l.mode {
1366 WithdrawLineMode::Off => None,
1367 WithdrawLineMode::All => Some(WorkerRouteWithdrawItem {
1368 template: l.template.clone(),
1369 qty: None,
1370 }),
1371 WithdrawLineMode::Qty(q) => Some(WorkerRouteWithdrawItem {
1372 template: l.template.clone(),
1373 qty: Some(q),
1374 }),
1375 })
1376 .collect()
1377 }
1378
1379 fn job_id(&self) -> String {
1382 format!(
1383 "route_{}",
1384 self.worker_instance_id
1385 .chars()
1386 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
1387 .collect::<String>()
1388 )
1389 }
1390
1391 pub fn build_idle_job_yaml(&self) -> String {
1395 let job_id = self.job_id();
1398 [
1399 format!("job_id: {job_id}"),
1400 "mode: idle".into(),
1401 "steps: []".into(),
1402 ]
1403 .join("\n")
1404 }
1405
1406 pub fn build_job_yaml(&self) -> Result<String, String> {
1407 if self.stops.is_empty() {
1408 return Err("add at least one stop (waypoint, harvest node, or deposit)".into());
1409 }
1410 let job_id = self.job_id();
1411 let mut lines = vec![
1412 format!("job_id: {job_id}"),
1413 "mode: job_loop".into(),
1414 "route:".into(),
1415 " kind: ordered".into(),
1416 ];
1417 if let Some(lodging) = &self.lodging_container_id {
1418 lines.push(format!(" lodging_container_id: {lodging}"));
1419 }
1420 lines.push(format!(
1421 " carry_return_ratio: {:.2}",
1422 self.carry_return_ratio
1423 ));
1424 lines.push(" stops:".into());
1425 for stop in &self.stops {
1426 match stop {
1427 WorkerRouteStop::Waypoint { x, y, z } => {
1428 lines.push(format!(
1429 " - {{ stop: waypoint, x: {:.1}, y: {:.1}, z: {:.1} }}",
1430 x, y, z
1431 ));
1432 }
1433 WorkerRouteStop::HarvestNode { node_id } => {
1434 lines.push(format!(
1435 " - {{ stop: harvest_node, node_id: {node_id} }}"
1436 ));
1437 }
1438 WorkerRouteStop::DepositAt {
1439 container_id,
1440 filter,
1441 } => {
1442 let f = filter
1443 .as_ref()
1444 .filter(|f| !f.is_empty())
1445 .map(|f| {
1446 format!(
1447 ", filter: [{}]",
1448 f.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
1449 )
1450 })
1451 .unwrap_or_default();
1452 lines.push(format!(
1453 " - {{ stop: deposit_at, container_id: {container_id}{f} }}"
1454 ));
1455 }
1456 WorkerRouteStop::RestIfNeeded => {
1457 lines.push(" - { stop: rest_if_needed }".into());
1458 }
1459 WorkerRouteStop::Wait { wait_ticks } => {
1460 lines.push(format!(" - {{ stop: wait, wait_ticks: {wait_ticks} }}"));
1461 }
1462 WorkerRouteStop::TradeWith {
1463 npc_id,
1464 template,
1465 sell_all,
1466 } => {
1467 let who = npc_id
1468 .as_deref()
1469 .map(|n| format!(", npc_id: {n}"))
1470 .unwrap_or_default();
1471 lines.push(format!(
1472 " - {{ stop: trade_with, template: {template}{who}, sell_all: {sell_all} }}"
1473 ));
1474 }
1475 WorkerRouteStop::ListOnMarket {
1476 template,
1477 list_all,
1478 hall_id,
1479 } => {
1480 let hall = hall_id
1481 .as_deref()
1482 .map(|h| format!(", hall_id: {h}"))
1483 .unwrap_or_default();
1484 lines.push(format!(
1485 " - {{ stop: list_on_market, template: {template}{hall}, list_all: {list_all} }}"
1486 ));
1487 }
1488 WorkerRouteStop::WithdrawFrom {
1489 container_id,
1490 items,
1491 } => {
1492 let mut block = format!(
1493 " - stop: withdraw_from\n container_id: {container_id}\n items:"
1494 );
1495 for it in items {
1496 let line = match it.qty {
1497 None => {
1498 format!("\n - {{ template: {}, all: true }}", it.template)
1499 }
1500 Some(q) => {
1501 format!("\n - {{ template: {}, qty: {} }}", it.template, q)
1502 }
1503 };
1504 block.push_str(&line);
1505 }
1506 lines.push(block);
1507 }
1508 WorkerRouteStop::CraftAt {
1509 device,
1510 blueprint,
1511 qty,
1512 } => {
1513 let qty_str = qty.map(|q| format!(", qty: {q}")).unwrap_or_default();
1514 lines.push(format!(
1515 " - {{ stop: craft_at, device: {device}, blueprint: {blueprint}{qty_str} }}"
1516 ));
1517 }
1518 WorkerRouteStop::CultivatePlot { plot_id } => {
1519 lines.push(format!(
1520 " - {{ stop: cultivate_plot, plot_id: \"{plot_id}\" }}"
1521 ));
1522 }
1523 WorkerRouteStop::PlantPlot {
1524 plot_id,
1525 seed_template,
1526 } => {
1527 lines.push(format!(
1528 " - {{ stop: plant_plot, plot_id: \"{plot_id}\", seed_template: {seed_template} }}"
1529 ));
1530 }
1531 WorkerRouteStop::HarvestPlot { plot_id } => {
1532 lines.push(format!(
1533 " - {{ stop: harvest_plot, plot_id: \"{plot_id}\" }}"
1534 ));
1535 }
1536 }
1537 }
1538 lines.push("steps: []".into());
1539 Ok(lines.join("\n"))
1540 }
1541
1542 pub fn to_route_view(&self) -> flatland_protocol::WorkerRouteView {
1544 use flatland_protocol::{
1545 WorkerRouteKindView, WorkerRouteStopView, WorkerRouteView, WorkerWithdrawItemView,
1546 };
1547 WorkerRouteView {
1548 kind: WorkerRouteKindView::Ordered,
1549 lodging_container_id: self.lodging_container_id.clone(),
1550 outbound_waypoints: Vec::new(),
1551 harvest_nodes: Vec::new(),
1552 carry_return_ratio: self.carry_return_ratio,
1553 stops: self
1554 .stops
1555 .iter()
1556 .map(|stop| match stop {
1557 WorkerRouteStop::Waypoint { x, y, z } => WorkerRouteStopView::Waypoint {
1558 x: *x,
1559 y: *y,
1560 z: *z,
1561 },
1562 WorkerRouteStop::HarvestNode { node_id } => WorkerRouteStopView::HarvestNode {
1563 node_id: node_id.clone(),
1564 },
1565 WorkerRouteStop::DepositAt {
1566 container_id,
1567 filter,
1568 } => WorkerRouteStopView::DepositAt {
1569 container_id: container_id.clone(),
1570 filter: filter.clone(),
1571 },
1572 WorkerRouteStop::TradeWith {
1573 npc_id,
1574 template,
1575 sell_all,
1576 } => WorkerRouteStopView::TradeWith {
1577 npc_id: npc_id.clone(),
1578 template: template.clone(),
1579 sell_all: *sell_all,
1580 },
1581 WorkerRouteStop::ListOnMarket {
1582 template,
1583 list_all,
1584 hall_id,
1585 } => WorkerRouteStopView::ListOnMarket {
1586 template: template.clone(),
1587 list_all: *list_all,
1588 hall_id: hall_id.clone(),
1589 },
1590 WorkerRouteStop::WithdrawFrom {
1591 container_id,
1592 items,
1593 } => WorkerRouteStopView::WithdrawFrom {
1594 container_id: container_id.clone(),
1595 items: items
1596 .iter()
1597 .map(|i| WorkerWithdrawItemView {
1598 template: i.template.clone(),
1599 qty: i.qty.unwrap_or(0),
1600 all: i.qty.is_none(),
1601 })
1602 .collect(),
1603 },
1604 WorkerRouteStop::CraftAt {
1605 device,
1606 blueprint,
1607 qty,
1608 } => WorkerRouteStopView::CraftAt {
1609 device: device.clone(),
1610 blueprint: blueprint.clone(),
1611 qty: *qty,
1612 },
1613 WorkerRouteStop::CultivatePlot { plot_id } => {
1614 WorkerRouteStopView::CultivatePlot { plot_id: *plot_id }
1615 }
1616 WorkerRouteStop::PlantPlot {
1617 plot_id,
1618 seed_template,
1619 } => WorkerRouteStopView::PlantPlot {
1620 plot_id: *plot_id,
1621 seed_template: seed_template.clone(),
1622 },
1623 WorkerRouteStop::HarvestPlot { plot_id } => {
1624 WorkerRouteStopView::HarvestPlot { plot_id: *plot_id }
1625 }
1626 WorkerRouteStop::RestIfNeeded => WorkerRouteStopView::RestIfNeeded,
1627 WorkerRouteStop::Wait { wait_ticks } => WorkerRouteStopView::Wait {
1628 wait_ticks: *wait_ticks,
1629 },
1630 })
1631 .collect(),
1632 }
1633 }
1634}
1635
1636fn stop_view_to_stop(view: &WorkerRouteStopView) -> WorkerRouteStop {
1637 match view {
1638 WorkerRouteStopView::Waypoint { x, y, z } => WorkerRouteStop::Waypoint {
1639 x: *x,
1640 y: *y,
1641 z: *z,
1642 },
1643 WorkerRouteStopView::HarvestNode { node_id } => WorkerRouteStop::HarvestNode {
1644 node_id: node_id.clone(),
1645 },
1646 WorkerRouteStopView::DepositAt {
1647 container_id,
1648 filter,
1649 } => WorkerRouteStop::DepositAt {
1650 container_id: container_id.clone(),
1651 filter: filter.clone(),
1652 },
1653 WorkerRouteStopView::TradeWith {
1654 npc_id,
1655 template,
1656 sell_all,
1657 } => WorkerRouteStop::TradeWith {
1658 npc_id: npc_id.clone(),
1659 template: template.clone(),
1660 sell_all: *sell_all,
1661 },
1662 WorkerRouteStopView::ListOnMarket {
1663 template,
1664 list_all,
1665 hall_id,
1666 } => WorkerRouteStop::ListOnMarket {
1667 template: template.clone(),
1668 list_all: *list_all,
1669 hall_id: hall_id.clone(),
1670 },
1671 WorkerRouteStopView::WithdrawFrom {
1672 container_id,
1673 items,
1674 } => WorkerRouteStop::WithdrawFrom {
1675 container_id: container_id.clone(),
1676 items: items
1677 .iter()
1678 .map(|i| WorkerRouteWithdrawItem {
1679 template: i.template.clone(),
1680 qty: if i.all { None } else { Some(i.qty) },
1681 })
1682 .collect(),
1683 },
1684 WorkerRouteStopView::CraftAt {
1685 device,
1686 blueprint,
1687 qty,
1688 } => WorkerRouteStop::CraftAt {
1689 device: device.clone(),
1690 blueprint: blueprint.clone(),
1691 qty: *qty,
1692 },
1693 WorkerRouteStopView::CultivatePlot { plot_id } => {
1694 WorkerRouteStop::CultivatePlot { plot_id: *plot_id }
1695 }
1696 WorkerRouteStopView::PlantPlot {
1697 plot_id,
1698 seed_template,
1699 } => WorkerRouteStop::PlantPlot {
1700 plot_id: *plot_id,
1701 seed_template: seed_template.clone(),
1702 },
1703 WorkerRouteStopView::HarvestPlot { plot_id } => {
1704 WorkerRouteStop::HarvestPlot { plot_id: *plot_id }
1705 }
1706 WorkerRouteStopView::RestIfNeeded => WorkerRouteStop::RestIfNeeded,
1707 WorkerRouteStopView::Wait { wait_ticks } => WorkerRouteStop::Wait {
1708 wait_ticks: *wait_ticks,
1709 },
1710 }
1711}
1712
1713const HARVEST_NODE_PICK_M: f32 = 4.0;
1716const LODGING_PICK_M: f32 = 5.0;
1717const STORAGE_PICK_M: f32 = 5.0;
1718
1719fn dist2d(x0: f32, y0: f32, x1: f32, y1: f32) -> f32 {
1720 let dx = x0 - x1;
1721 let dy = y0 - y1;
1722 (dx * dx + dy * dy).sqrt()
1723}
1724
1725pub fn pick_resource_node_at<'a>(
1727 nodes: &'a [ResourceNodeView],
1728 x: f32,
1729 y: f32,
1730) -> Option<&'a ResourceNodeView> {
1731 nodes
1732 .iter()
1733 .filter(|n| !n.harvest_off)
1734 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
1735 .filter_map(|n| {
1736 let d = dist2d(x, y, n.x, n.y);
1737 if d <= HARVEST_NODE_PICK_M {
1738 Some((d, n))
1739 } else {
1740 None
1741 }
1742 })
1743 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1744 .map(|(_, n)| n)
1745}
1746
1747pub fn owned_lodging_container_ids(
1749 placed: &[PlacedContainerView],
1750 character_id: Option<uuid::Uuid>,
1751) -> Vec<(String, String)> {
1752 owned_lodging_container_ids_with_occupants(placed, character_id, &[])
1753}
1754
1755pub fn owned_lodging_container_ids_with_occupants(
1757 placed: &[PlacedContainerView],
1758 character_id: Option<uuid::Uuid>,
1759 hired: &[flatland_protocol::HiredWorkerView],
1760) -> Vec<(String, String)> {
1761 let Some(cid) = character_id else {
1762 return Vec::new();
1763 };
1764 let mut out: Vec<(String, String)> = placed
1765 .iter()
1766 .filter(|c| c.worker_lodging_capacity.unwrap_or(0) > 0)
1767 .filter(|c| c.owner_character_id == Some(cid))
1768 .map(|c| {
1769 let who = lodging_occupants_for(hired, &c.id);
1770 let name = if who.is_empty() {
1771 format!("{} — vacant", c.display_name)
1772 } else {
1773 format!("{} — {}", c.display_name, who.join(", "))
1774 };
1775 (c.id.clone(), name)
1776 })
1777 .collect();
1778 out.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
1779 out
1780}
1781
1782pub fn pick_lodging_container_at(
1783 placed: &[PlacedContainerView],
1784 character_id: Option<uuid::Uuid>,
1785 x: f32,
1786 y: f32,
1787 observer_inside: Option<&str>,
1788) -> Option<String> {
1789 let cid = character_id?;
1790 placed
1791 .iter()
1792 .filter(|c| container_in_observer_space(c, observer_inside))
1793 .filter(|c| c.worker_lodging_capacity.unwrap_or(0) > 0)
1794 .filter(|c| c.owner_character_id == Some(cid))
1795 .filter_map(|c| {
1796 let d = dist2d(x, y, c.x, c.y);
1797 if d <= LODGING_PICK_M {
1798 Some((d, c.id.clone()))
1799 } else {
1800 None
1801 }
1802 })
1803 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1804 .map(|(_, id)| id)
1805}
1806
1807pub fn pick_trade_npc_at(npcs: &[NpcView], x: f32, y: f32) -> Option<(String, String)> {
1809 const NPC_PICK_M: f32 = 5.0;
1810 npcs.iter()
1811 .filter(|n| n.can_trade)
1812 .filter_map(|n| {
1813 let d = dist2d(x, y, n.x, n.y);
1814 if d <= NPC_PICK_M {
1815 Some((d, n.id.clone(), n.label.clone()))
1816 } else {
1817 None
1818 }
1819 })
1820 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1821 .map(|(_, id, label)| (id, label))
1822}
1823
1824pub fn owned_storage_template_ids(
1827 placed: &[PlacedContainerView],
1828 character_id: Option<uuid::Uuid>,
1829) -> Vec<String> {
1830 let Some(cid) = character_id else {
1831 return Vec::new();
1832 };
1833 let mut out: Vec<String> = placed
1834 .iter()
1835 .filter(|c| c.owner_character_id == Some(cid))
1836 .flat_map(|c| c.contents.iter().map(|s| s.template_id.clone()))
1837 .filter(|t| !t.is_empty())
1838 .collect();
1839 out.sort();
1840 out.dedup();
1841 out
1842}
1843
1844pub fn worker_craft_blueprint_ids(
1851 blueprints: &[flatland_protocol::BlueprintView],
1852 known_blueprint_ids: Option<&[String]>,
1853) -> Vec<String> {
1854 let mut ids: Vec<String> = blueprints.iter().map(|b| b.id.clone()).collect();
1855 if let Some(known) = known_blueprint_ids {
1856 if !known.is_empty() {
1857 ids.retain(|id| known.iter().any(|k| k == id));
1858 }
1859 }
1860 ids
1861}
1862
1863pub fn route_item_template_candidates(
1869 placed: &[PlacedContainerView],
1870 character_id: Option<uuid::Uuid>,
1871 inventory: &std::collections::HashMap<String, u32>,
1872 blueprints: &[flatland_protocol::BlueprintView],
1873 resource_nodes: &[flatland_protocol::ResourceNodeView],
1874 extra: &[String],
1875 catalog: Option<&std::collections::HashMap<String, ItemCatalogEntryView>>,
1876) -> Vec<String> {
1877 let mut out = owned_storage_template_ids(placed, character_id);
1878 for (template, qty) in inventory {
1879 if *qty > 0 && !template.is_empty() {
1880 out.push(template.clone());
1881 }
1882 }
1883 for bp in blueprints {
1884 if !bp.output.is_empty() {
1885 out.push(bp.output.clone());
1886 }
1887 for input in &bp.inputs {
1888 if !input.template_id.is_empty() {
1889 out.push(input.template_id.clone());
1890 }
1891 }
1892 for tool in &bp.required_tools {
1893 if !tool.item.is_empty() {
1894 out.push(tool.item.clone());
1895 }
1896 }
1897 }
1898 for node in resource_nodes {
1899 for t in &node.harvest_drop_templates {
1901 if !t.is_empty() {
1902 out.push(t.clone());
1903 }
1904 }
1905 }
1906 for t in extra {
1907 if !t.is_empty() {
1908 out.push(t.clone());
1909 }
1910 }
1911 out.sort();
1912 out.dedup();
1913 if let Some(catalog) = catalog {
1914 out.retain(|id| catalog.get(id).is_none_or(|e| e.is_depositable_stack()));
1915 }
1916 out
1917}
1918
1919pub fn sellable_route_item_template_candidates(
1925 templates: &[String],
1926 npcs: &[flatland_protocol::NpcView],
1927 npc_id: Option<&str>,
1928) -> Vec<String> {
1929 let accepted = npcs
1930 .iter()
1931 .filter(|npc| npc_id.is_none_or(|id| npc.id == id))
1932 .filter(|npc| npc_id.is_some() || npc.can_trade)
1933 .flat_map(|npc| npc.buy_templates.iter().map(String::as_str))
1934 .collect::<std::collections::HashSet<_>>();
1935 let mut out: Vec<String> = templates
1936 .iter()
1937 .filter(|template| accepted.contains(template.as_str()))
1938 .cloned()
1939 .collect();
1940 out.sort();
1941 out.dedup();
1942 out
1943}
1944
1945pub fn pick_storage_container_at(
1949 placed: &[PlacedContainerView],
1950 character_id: Option<uuid::Uuid>,
1951 x: f32,
1952 y: f32,
1953 observer_inside: Option<&str>,
1954) -> Option<String> {
1955 let cid = character_id?;
1956 placed
1957 .iter()
1958 .filter(|c| container_in_observer_space(c, observer_inside))
1959 .filter(|c| c.owner_character_id == Some(cid))
1960 .filter(|c| c.capacity_volume.unwrap_or(0.0) > 0.0)
1961 .filter_map(|c| {
1962 let d = dist2d(x, y, c.x, c.y);
1963 if d <= STORAGE_PICK_M {
1964 Some((d, c.id.clone()))
1965 } else {
1966 None
1967 }
1968 })
1969 .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1970 .map(|(_, id)| id)
1971}
1972
1973fn container_in_observer_space(c: &PlacedContainerView, observer_inside: Option<&str>) -> bool {
1974 match (observer_inside, c.building_id.as_deref()) {
1975 (None, None) => true,
1976 (Some(a), Some(b)) => a == b,
1977 _ => false,
1978 }
1979}
1980
1981#[cfg(test)]
1982mod tests {
1983 use super::*;
1984
1985 #[test]
1986 fn worker_craft_blueprint_ids_filters_to_known_recipes() {
1987 use flatland_protocol::{BlueprintIngredientView, BlueprintView};
1988
1989 fn bp(id: &str) -> BlueprintView {
1990 BlueprintView {
1991 id: id.into(),
1992 label: id.into(),
1993 output: "x".into(),
1994 output_qty: 1,
1995 craft_ticks: 1,
1996 inputs: vec![BlueprintIngredientView {
1997 template_id: "oak_log".into(),
1998 quantity: 1,
1999 consumed: true,
2000 display_name: "Oak Log".into(),
2001 }],
2002 station: Some("hand".into()),
2003 category: None,
2004 required_tools: vec![],
2005 skill: None,
2006 failure_chance: 0.0,
2007 worker_train_copper: 0,
2008 output_display_name: "X".into(),
2009 craft_tier: 1,
2010 }
2011 }
2012
2013 let all = vec![
2014 bp("oak_to_lumber"),
2015 bp("vegetable_soup"),
2016 bp("craft_simple_camp_bed"),
2017 bp("craft_wooden_chest_small"),
2018 bp("iron_ingot"),
2019 ];
2020 let known = vec![
2021 "oak_to_lumber".into(),
2022 "craft_simple_camp_bed".into(),
2023 "craft_wooden_chest_small".into(),
2024 ];
2025
2026 let filtered = worker_craft_blueprint_ids(&all, Some(&known));
2027 assert_eq!(
2028 filtered,
2029 vec![
2030 "oak_to_lumber",
2031 "craft_simple_camp_bed",
2032 "craft_wooden_chest_small"
2033 ]
2034 );
2035
2036 assert_eq!(worker_craft_blueprint_ids(&all, Some(&[])).len(), all.len());
2038 assert_eq!(worker_craft_blueprint_ids(&all, None).len(), all.len());
2039 }
2040
2041 #[test]
2042 fn route_item_candidates_include_craft_outputs_not_in_storage() {
2043 use flatland_protocol::{
2044 BlueprintIngredientView, BlueprintView, PlacedContainerView, ResourceNodeState,
2045 ResourceNodeView,
2046 };
2047 use std::collections::HashMap;
2048 use uuid::Uuid;
2049
2050 let cid = Uuid::from_u128(0x1111_2222_3333_4444_5555_6666_7777_8888);
2051 let placed = vec![PlacedContainerView {
2052 id: "chest-1".into(),
2053 template_id: "wooden_chest_small".into(),
2054 display_name: "Chest".into(),
2055 x: 0.0,
2056 y: 0.0,
2057 z: 0.0,
2058 locked: false,
2059 accessible: true,
2060 owner_character_id: Some(cid),
2061 contents: vec![],
2062 lock_id: None,
2063 capacity_volume: Some(20.0),
2064 item_instance_id: None,
2065 tile_id: None,
2066 worker_lodging_capacity: None,
2067 blocking: false,
2068 blocking_radius_m: 0.0,
2069 building_id: None,
2070 }];
2071 let blueprints = vec![BlueprintView {
2072 id: "smelt_iron".into(),
2073 label: "Smelt Iron".into(),
2074 output: "iron_ingot".into(),
2075 output_qty: 1,
2076 craft_ticks: 30,
2077 inputs: vec![BlueprintIngredientView {
2078 template_id: "iron_ore".into(),
2079 quantity: 1,
2080 consumed: true,
2081 display_name: "Iron Ore".into(),
2082 }],
2083 station: Some("hand".into()),
2084 category: None,
2085 required_tools: vec![],
2086 skill: None,
2087 failure_chance: 0.0,
2088 worker_train_copper: 0,
2089 output_display_name: "Iron Ingot".into(),
2090 craft_tier: 1,
2091 }];
2092 let nodes = vec![ResourceNodeView {
2093 id: "ore-1".into(),
2094 label: "Iron Ore".into(),
2095 x: 1.0,
2096 y: 1.0,
2097 z: 0.0,
2098 item_template: "iron_ore".into(),
2099 state: ResourceNodeState::Available,
2100 blocking: true,
2101 blocking_radius_m: 0.8,
2102 harvest_off: false,
2103 tile_id: None,
2104 yaw: 0.0,
2105 pitch: 0.0,
2106 roll: 0.0,
2107 draw_scale: 1.0,
2108 sprite_mode: None,
2109 growth_progress: None,
2110 presentation_state: None,
2111 channel_start_tick: None,
2112 channel_end_tick: None,
2113 harvest_drop_templates: vec![],
2114 }];
2115 let ids = route_item_template_candidates(
2116 &placed,
2117 Some(cid),
2118 &HashMap::new(),
2119 &blueprints,
2120 &nodes,
2121 &[],
2122 None,
2123 );
2124 assert!(
2125 ids.contains(&"iron_ingot".to_string()),
2126 "craft output should be selectable before any exists in storage: {ids:?}"
2127 );
2128 assert!(ids.contains(&"iron_ore".to_string()));
2129 }
2130
2131 #[test]
2132 fn sellable_route_candidates_follow_npc_buy_lists() {
2133 use flatland_protocol::NpcView;
2134
2135 fn npc(id: &str, can_trade: bool, buy_templates: &[&str]) -> NpcView {
2136 NpcView {
2137 id: id.into(),
2138 label: id.into(),
2139 role: "merchant".into(),
2140 x: 0.0,
2141 y: 0.0,
2142 building_id: None,
2143 entity_id: None,
2144 life_state: None,
2145 hp_pct: None,
2146 can_trade,
2147 buy_templates: buy_templates.iter().map(|t| (*t).into()).collect(),
2148 tile_id: None,
2149 behavior_state: None,
2150 presentation_state: None,
2151 sprite_mode: None,
2152 paperdoll_ref: None,
2153 draw_scale: 1.0,
2154 yaw: None,
2155 perception_fov_deg: None,
2156 perception_sight_m: None,
2157 perception_hear_m: None,
2158 quest_verbs: Vec::new(),
2159 }
2160 }
2161
2162 let templates = vec![
2163 "carrot".into(),
2164 "carrot_wild".into(),
2165 "carrot_seed".into(),
2166 "oak_log".into(),
2167 ];
2168 let npcs = vec![
2169 npc("maris", true, &["carrot"]),
2170 npc("eli", true, &["carrot_seed"]),
2171 npc("wildlife", false, &["oak_log"]),
2172 ];
2173
2174 assert_eq!(
2175 sellable_route_item_template_candidates(&templates, &npcs, Some("maris")),
2176 vec!["carrot"]
2177 );
2178 assert_eq!(
2179 sellable_route_item_template_candidates(&templates, &npcs, None),
2180 vec!["carrot", "carrot_seed"]
2181 );
2182 }
2183
2184 #[test]
2185 fn trade_npc_candidates_mark_non_overlapping_merchants() {
2186 use flatland_protocol::NpcView;
2187
2188 fn npc(id: &str, label: &str, can_trade: bool, buy_templates: &[&str], x: f32) -> NpcView {
2189 NpcView {
2190 id: id.into(),
2191 label: label.into(),
2192 role: "merchant".into(),
2193 x,
2194 y: 0.0,
2195 building_id: None,
2196 entity_id: None,
2197 life_state: None,
2198 hp_pct: None,
2199 can_trade,
2200 buy_templates: buy_templates.iter().map(|t| (*t).into()).collect(),
2201 tile_id: None,
2202 behavior_state: None,
2203 presentation_state: None,
2204 sprite_mode: None,
2205 paperdoll_ref: None,
2206 draw_scale: 1.0,
2207 yaw: None,
2208 perception_fov_deg: None,
2209 perception_sight_m: None,
2210 perception_hear_m: None,
2211 quest_verbs: Vec::new(),
2212 }
2213 }
2214
2215 let route = vec!["carrot".into(), "potato".into()];
2216 let npcs = vec![
2217 npc("mira_market", "Mira", true, &[], 10.0),
2218 npc("ada_broker", "Ada", true, &["lumber", "oak_log"], 5.0),
2219 npc("maris_cook", "Maris", true, &["carrot"], 20.0),
2220 npc("wildlife", "Wolf", false, &["carrot"], 1.0),
2221 ];
2222 let cands = trade_npc_candidates(&npcs, 0.0, 0.0, &route);
2223 assert_eq!(cands.len(), 3, "only can_trade NPCs: {cands:?}");
2224 assert!(cands[0].buys_route_item && cands[0].id == "maris_cook");
2225 assert!(!cands.iter().any(|c| c.id == "wildlife"));
2226 let ada = cands.iter().find(|c| c.id == "ada_broker").unwrap();
2227 assert!(!ada.buys_route_item);
2228 let mira = cands.iter().find(|c| c.id == "mira_market").unwrap();
2229 assert!(!mira.buys_route_item);
2230 assert!(any_trade_npc_buys_route_item(&npcs, &route));
2231 assert!(!any_trade_npc_buys_route_item(
2232 &npcs,
2233 &["iron_ingot".into()]
2234 ));
2235 assert!(sell_merchant_empty_reason(Some("mira_market"), &npcs, &route)
2236 .contains("empty buy list"));
2237 assert!(sell_merchant_empty_reason(Some("ada_broker"), &npcs, &route)
2238 .contains("doesn't buy any of your route items"));
2239 assert!(sell_merchant_empty_reason(None, &npcs, &["iron_ingot".into()])
2240 .contains("no trade NPC buys"));
2241 }
2242
2243 #[test]
2244 fn route_item_template_includes_harvest_loot_table_drops() {
2245 use flatland_protocol::{ResourceNodeState, ResourceNodeView};
2246 use std::collections::HashMap;
2247 let nodes = vec![ResourceNodeView {
2248 id: "crop-carrot-1".into(),
2249 label: "Wild carrots".into(),
2250 x: 1.0,
2251 y: 1.0,
2252 z: 0.0,
2253 item_template: "carrot_wild".into(),
2254 state: ResourceNodeState::Available,
2255 blocking: false,
2256 blocking_radius_m: 0.8,
2257 harvest_off: false,
2258 tile_id: None,
2259 yaw: 0.0,
2260 pitch: 0.0,
2261 roll: 0.0,
2262 draw_scale: 1.0,
2263 sprite_mode: None,
2264 growth_progress: None,
2265 presentation_state: None,
2266 channel_start_tick: None,
2267 channel_end_tick: None,
2268 harvest_drop_templates: vec!["carrot".into(), "carrot_seed".into()],
2269 }];
2270 let ids =
2271 route_item_template_candidates(&[], None, &HashMap::new(), &[], &nodes, &[], None);
2272 assert!(
2273 ids.contains(&"carrot_seed".to_string()),
2274 "deposit filter should list seeds from harvest loot tables: {ids:?}"
2275 );
2276 assert!(
2277 !ids.contains(&"carrot_wild".to_string()),
2278 "harvest_node templates are not deposit stacks: {ids:?}"
2279 );
2280 }
2281
2282 #[test]
2283 fn route_item_candidates_drop_harvest_node_catalog_entries() {
2284 use flatland_protocol::{ResourceNodeState, ResourceNodeView};
2285 use std::collections::HashMap;
2286 let rocks = "3ee51931-189c-4726-828b-dffb1a3d1fc4";
2287 let stone = "592fe396-cc6e-42d8-8554-4080c3b19036";
2288 let nodes = vec![ResourceNodeView {
2289 id: "rocks-1".into(),
2290 label: "Rocks".into(),
2291 x: 1.0,
2292 y: 1.0,
2293 z: 0.0,
2294 item_template: rocks.into(),
2295 state: ResourceNodeState::Available,
2296 blocking: false,
2297 blocking_radius_m: 0.8,
2298 harvest_off: false,
2299 tile_id: None,
2300 yaw: 0.0,
2301 pitch: 0.0,
2302 roll: 0.0,
2303 draw_scale: 1.0,
2304 sprite_mode: None,
2305 growth_progress: None,
2306 presentation_state: None,
2307 channel_start_tick: None,
2308 channel_end_tick: None,
2309 harvest_drop_templates: vec![stone.into()],
2310 }];
2311 let mut catalog = HashMap::new();
2312 catalog.insert(
2313 rocks.to_string(),
2314 ItemCatalogEntryView {
2315 template_id: rocks.into(),
2316 display_name: "Rocks".into(),
2317 category: "harvest_node".into(),
2318 seed_for: None,
2319 },
2320 );
2321 catalog.insert(
2322 stone.to_string(),
2323 ItemCatalogEntryView {
2324 template_id: stone.into(),
2325 display_name: "Rough Stone".into(),
2326 category: "resource".into(),
2327 seed_for: None,
2328 },
2329 );
2330 let ids = route_item_template_candidates(
2331 &[],
2332 None,
2333 &HashMap::new(),
2334 &[],
2335 &nodes,
2336 &[rocks.into()],
2337 Some(&catalog),
2338 );
2339 assert!(
2340 ids.contains(&stone.to_string()),
2341 "loot drops stay selectable: {ids:?}"
2342 );
2343 assert!(
2344 !ids.contains(&rocks.to_string()),
2345 "Rocks harvest_node must not appear in deposit filter: {ids:?}"
2346 );
2347 }
2348
2349 #[test]
2350 fn build_ordered_job_yaml_includes_stops() {
2351 let mut ed = WorkerRouteEditorState::new(
2352 "worker-worker_laborer-1".into(),
2353 "Laborer".into(),
2354 Some("chest-bed".into()),
2355 );
2356 ed.append_waypoint(10.0, 20.0, 0.0);
2357 ed.append_harvest_node("oak-n1");
2358 ed.append_deposit_at("chest-storage-a");
2359 ed.append_rest_if_needed();
2360 let yaml = ed.build_job_yaml().expect("yaml");
2361 assert!(yaml.contains("kind: ordered"));
2362 assert!(yaml.contains("lodging_container_id: chest-bed"));
2363 assert!(yaml.contains("stop: waypoint"));
2364 assert!(yaml.contains("oak-n1"));
2365 assert!(yaml.contains("deposit_at"));
2366 assert!(yaml.contains("chest-storage-a"));
2367 assert!(yaml.contains("rest_if_needed"));
2368 }
2369
2370 #[test]
2371 fn requires_at_least_one_stop() {
2372 let ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2373 assert!(ed.build_job_yaml().is_err());
2374 }
2375
2376 #[test]
2377 fn reorder_stops() {
2378 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2379 ed.append_harvest_node("oak-a");
2380 ed.append_harvest_node("oak-b");
2381 ed.append_waypoint(5.0, 6.0, 0.0);
2382 ed.select_stop(1);
2384 ed.move_selected_up();
2385 assert!(
2386 matches!(&ed.stops[0], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
2387 );
2388 ed.move_selected_down();
2390 assert!(
2391 matches!(&ed.stops[1], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
2392 );
2393 }
2394
2395 #[test]
2396 fn duplicate_harvest_node_selects_existing_instead() {
2397 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2398 assert!(ed.append_harvest_node("oak-a"));
2399 ed.append_waypoint(1.0, 2.0, 0.0);
2400 assert!(!ed.append_harvest_node("oak-a"));
2401 assert_eq!(ed.stops.len(), 2);
2402 assert_eq!(ed.selected_stop_index, 0);
2403 }
2404
2405 #[test]
2406 fn duplicate_deposit_container_selects_existing_instead() {
2407 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2408 assert!(ed.append_deposit_at("chest-1"));
2409 ed.append_harvest_node("oak-a");
2410 assert!(!ed.append_deposit_at("chest-1"));
2411 assert_eq!(ed.stops.len(), 2);
2412 assert_eq!(ed.selected_stop_index, 0);
2413 }
2414
2415 #[test]
2416 fn duplicate_trade_stop_selects_existing_instead() {
2417 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2418 assert!(ed.append_trade_with("oak_log".into(), Some("ada".into()), true));
2419 assert!(!ed.append_trade_with("oak_log".into(), Some("ada".into()), true));
2420 assert!(ed.append_trade_with("lumber".into(), Some("ada".into()), true));
2422 assert_eq!(ed.stops.len(), 2);
2423 }
2424
2425 #[test]
2426 fn build_idle_job_yaml_parks_worker() {
2427 let ed = WorkerRouteEditorState::new("w1".into(), "L".into(), Some("bed-1".into()));
2428 let yaml = ed.build_idle_job_yaml();
2429 assert!(yaml.contains("mode: idle"));
2430 assert!(yaml.contains("steps: []"));
2431 assert!(!yaml.contains("route:"));
2432 }
2433
2434 #[test]
2435 fn remove_selected_stop_adjusts_index() {
2436 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2437 ed.append_waypoint(1.0, 2.0, 0.0);
2438 ed.append_harvest_node("oak-a");
2439 ed.append_deposit_at("chest-1");
2440 ed.select_stop(2);
2441 ed.remove_selected_stop();
2442 assert_eq!(ed.stops.len(), 2);
2443 assert_eq!(ed.selected_stop_index, 1);
2444 }
2445
2446 #[test]
2447 fn build_job_yaml_includes_trade_with_stop() {
2448 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2449 ed.append_trade_with("oak_log".into(), None, true);
2450 ed.append_trade_with("lumber".into(), Some("ada_broker".into()), false);
2451 let yaml = ed.build_job_yaml().expect("yaml");
2452 assert!(yaml.contains("stop: trade_with, template: oak_log, sell_all: true"));
2453 assert!(yaml.contains("npc_id: ada_broker"));
2454 assert!(yaml.contains("sell_all: false"));
2455 }
2456
2457 #[test]
2458 fn build_job_yaml_includes_list_on_market_stop() {
2459 let mut ed = WorkerRouteEditorState::new("w1".into(), "Laborer".into(), None);
2460 let _ = ed.insert_stop(WorkerRouteStop::ListOnMarket {
2461 template: "carrot".into(),
2462 list_all: true,
2463 hall_id: Some("town_market".into()),
2464 });
2465 let yaml = ed.build_job_yaml().expect("yaml");
2466 assert!(yaml.contains("list_on_market"), "{yaml}");
2467 assert!(yaml.contains("template: carrot"), "{yaml}");
2468 assert!(yaml.contains("hall_id: town_market"), "{yaml}");
2469 assert!(yaml.contains("list_all: true"), "{yaml}");
2470 }
2471
2472
2473 #[test]
2474 fn set_selected_trade_npc_updates_stop() {
2475 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2476 ed.append_trade_with("oak_log".into(), None, true);
2477 assert!(ed.set_selected_trade_npc("ada_broker".into()));
2478 assert!(
2479 matches!(&ed.stops[0], WorkerRouteStop::TradeWith { npc_id, .. } if npc_id.as_deref() == Some("ada_broker"))
2480 );
2481 }
2482
2483 #[test]
2484 fn build_job_yaml_deposit_filter_round_trips() {
2485 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), Some("bed-1".into()));
2486 ed.append_deposit_at_filtered("chest-out", vec!["lumber".into()]);
2487 let yaml = ed.build_job_yaml().expect("yaml");
2488 assert!(yaml.contains("stop: deposit_at, container_id: chest-out, filter: [lumber]"));
2489 }
2490
2491 #[test]
2492 fn build_job_yaml_includes_withdraw_and_craft_stops() {
2493 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2494 ed.stops.push(WorkerRouteStop::WithdrawFrom {
2495 container_id: "chest-src".into(),
2496 items: vec![WorkerRouteWithdrawItem {
2497 template: "oak_log".into(),
2498 qty: None,
2499 }],
2500 });
2501 ed.stops.push(WorkerRouteStop::WithdrawFrom {
2502 container_id: "chest-src-2".into(),
2503 items: vec![WorkerRouteWithdrawItem {
2504 template: "iron_ore".into(),
2505 qty: Some(10),
2506 }],
2507 });
2508 ed.stops.push(WorkerRouteStop::CraftAt {
2509 device: "hand".into(),
2510 blueprint: "oak_to_lumber".into(),
2511 qty: None,
2512 });
2513 ed.append_deposit_at("chest-out");
2514 let yaml = ed.build_job_yaml().expect("yaml");
2515 assert!(yaml.contains("stop: withdraw_from"));
2516 assert!(yaml.contains("container_id: chest-src"));
2517 assert!(yaml.contains("template: oak_log, all: true"));
2518 assert!(yaml.contains("template: iron_ore, qty: 10"));
2519 assert!(yaml.contains("stop: craft_at, device: hand, blueprint: oak_to_lumber"));
2520 assert!(yaml.contains("stop: deposit_at"));
2521 }
2522
2523 #[test]
2524 fn withdraw_summary_shows_all_vs_qty() {
2525 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2526 ed.stops.push(WorkerRouteStop::WithdrawFrom {
2527 container_id: "chest-src".into(),
2528 items: vec![WorkerRouteWithdrawItem {
2529 template: "oak_log".into(),
2530 qty: None,
2531 }],
2532 });
2533 assert!(ed.stops[0].summary().contains("withdraw all oak_log"));
2534 }
2535
2536 #[test]
2537 fn withdraw_view_round_trips_all_flag() {
2538 let view = WorkerRouteStopView::WithdrawFrom {
2539 container_id: "chest-1".into(),
2540 items: vec![
2541 flatland_protocol::WorkerWithdrawItemView {
2542 template: "oak_log".into(),
2543 qty: 0,
2544 all: true,
2545 },
2546 flatland_protocol::WorkerWithdrawItemView {
2547 template: "iron_ore".into(),
2548 qty: 5,
2549 all: false,
2550 },
2551 ],
2552 };
2553 let stop = stop_view_to_stop(&view);
2554 let WorkerRouteStop::WithdrawFrom { items, .. } = stop else {
2555 panic!("expected withdraw stop");
2556 };
2557 assert_eq!(items[0].qty, None);
2558 assert_eq!(items[1].qty, Some(5));
2559 }
2560
2561 #[test]
2562 fn legacy_harvest_loop_route_converts_to_ordered_stops() {
2563 let route = WorkerRouteView {
2564 kind: WorkerRouteKindView::HarvestLoop,
2565 lodging_container_id: Some("bed-1".into()),
2566 outbound_waypoints: vec![flatland_protocol::WorkerRouteWaypointView {
2567 x: 1.0,
2568 y: 2.0,
2569 z: 0.0,
2570 }],
2571 harvest_nodes: vec!["oak-1".into()],
2572 carry_return_ratio: 0.9,
2573 stops: Vec::new(),
2574 };
2575 let ed = WorkerRouteEditorState::from_saved_route("w1".into(), "L".into(), &route, None);
2576 assert_eq!(ed.stops.len(), 4);
2578 assert!(
2579 matches!(&ed.stops[2], WorkerRouteStop::DepositAt { container_id, .. } if container_id == "bed-1")
2580 );
2581 assert!(matches!(&ed.stops[3], WorkerRouteStop::RestIfNeeded));
2582 }
2583
2584 #[test]
2587 fn retarget_withdraw_container_updates_editing_stop() {
2588 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2589 ed.stops.push(WorkerRouteStop::WithdrawFrom {
2590 container_id: "chest-old".into(),
2591 items: vec![WorkerRouteWithdrawItem {
2592 template: "iron_ore".into(),
2593 qty: None,
2594 }],
2595 });
2596 ed.selected_stop_index = 0;
2597 ed.editing_index = Some(0);
2598 assert!(ed.retarget_withdraw_container("chest-new".into()));
2599 assert!(matches!(
2600 &ed.stops[0],
2601 WorkerRouteStop::WithdrawFrom { container_id, .. } if container_id == "chest-new"
2602 ));
2603 }
2604
2605 #[test]
2606 fn summary_resolved_uses_friendly_labels() {
2607 let stop = WorkerRouteStop::WithdrawFrom {
2608 container_id: "uuid-iron".into(),
2609 items: vec![WorkerRouteWithdrawItem {
2610 template: "iron_ore".into(),
2611 qty: None,
2612 }],
2613 };
2614 let summary = stop.summary_resolved(
2615 |_| "Iron Ore Container".into(),
2616 |_| "Ada".into(),
2617 |_| "Oak Tree".into(),
2618 |_| "Food Pad".into(),
2619 );
2620 assert_eq!(summary, "withdraw all iron_ore from Iron Ore Container");
2621 assert!(!summary.contains("uuid"));
2622 }
2623
2624 #[test]
2625 fn summary_resolved_uses_plot_labels() {
2626 let plot_id = uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap();
2627 let cultivate = WorkerRouteStop::CultivatePlot { plot_id };
2628 let plant = WorkerRouteStop::PlantPlot {
2629 plot_id,
2630 seed_template: "potato_seed".into(),
2631 };
2632 let harvest = WorkerRouteStop::HarvestPlot { plot_id };
2633 let friendly = |id: &uuid::Uuid| {
2634 assert_eq!(*id, plot_id);
2635 "Madsin — Starter Town East — Food Pad".to_string()
2636 };
2637 let blank = |_: &str| String::new();
2638 assert_eq!(
2639 cultivate.summary_resolved(blank, blank, blank, friendly),
2640 "cultivate Madsin — Starter Town East — Food Pad"
2641 );
2642 assert_eq!(
2643 plant.summary_resolved(blank, blank, blank, friendly),
2644 "plant potato_seed on Madsin — Starter Town East — Food Pad"
2645 );
2646 assert_eq!(
2647 harvest.summary_resolved(blank, blank, blank, friendly),
2648 "harvest Madsin — Starter Town East — Food Pad"
2649 );
2650 let named = cultivate.summary_resolved(blank, blank, blank, friendly);
2651 assert!(
2652 !named.contains("19fe35f"),
2653 "resolved plot summary must not include hex id: {named}"
2654 );
2655 let raw = cultivate.summary();
2656 assert_eq!(raw, "cultivate plot 19fe35f0");
2657 }
2658
2659 #[test]
2660 fn list_filter_row_matches_name_and_distance() {
2661 assert!(list_filter_row_matches(
2662 "oak",
2663 None,
2664 &["Oak Tree", "oak_log"]
2665 ));
2666 assert!(!list_filter_row_matches(
2667 "pine",
2668 None,
2669 &["Oak Tree", "oak_log"]
2670 ));
2671 assert!(list_filter_row_matches(
2672 "oak 50m",
2673 Some(40.0),
2674 &["Oak Tree"]
2675 ));
2676 assert!(!list_filter_row_matches(
2677 "oak 50m",
2678 Some(60.0),
2679 &["Oak Tree"]
2680 ));
2681 assert!(list_filter_row_matches("", Some(999.0), &["anything"]));
2682 }
2683
2684 #[test]
2685 fn node_candidates_sort_from_lodging_anchor_not_player() {
2686 use flatland_protocol::{ResourceNodeState, ResourceNodeView};
2687 fn node(id: &str, label: &str, x: f32) -> ResourceNodeView {
2688 ResourceNodeView {
2689 id: id.into(),
2690 label: label.into(),
2691 x,
2692 y: 0.0,
2693 z: 0.0,
2694 item_template: "oak_log".into(),
2695 state: ResourceNodeState::Available,
2696 blocking: true,
2697 blocking_radius_m: 0.8,
2698 harvest_off: false,
2699 tile_id: None,
2700 yaw: 0.0,
2701 pitch: 0.0,
2702 roll: 0.0,
2703 draw_scale: 1.0,
2704 sprite_mode: None,
2705 presentation_state: None,
2706 growth_progress: None,
2707 channel_start_tick: None,
2708 channel_end_tick: None,
2709 harvest_drop_templates: vec![],
2710 }
2711 }
2712 let nodes = vec![node("far", "Far Oak", 100.0), node("near", "Near Oak", 5.0)];
2713 let sorted = node_candidates(&nodes, 0.0, 0.0);
2714 assert_eq!(sorted[0].id, "near");
2715 assert_eq!(sorted[1].id, "far");
2716 assert!((sorted[0].dist - 5.0).abs() < 0.01);
2717
2718 let stable = node_candidates_stable(&nodes);
2719 assert!(
2720 stable[0].label.starts_with("Far Oak ("),
2721 "got {}",
2722 stable[0].label
2723 );
2724 assert!(
2725 stable[1].label.starts_with("Near Oak ("),
2726 "got {}",
2727 stable[1].label
2728 );
2729 assert!(stable[0].dist.is_nan());
2730 }
2731
2732 #[test]
2733 fn sheet_back_walks_up_hierarchy() {
2734 use RouteEditorSheet as S;
2735 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2736 assert_eq!(ed.sheet, S::Stops);
2737 ed.open_add_menu();
2738 assert_eq!(ed.sheet, S::AddMenu { index: 0 });
2739 ed.open_sheet(S::WithdrawContainers { index: 0 });
2740 ed.open_sheet(S::WithdrawItems {
2741 container_id: "c1".into(),
2742 lines: vec![],
2743 index: 0,
2744 });
2745 ed.sheet_back();
2746 assert_eq!(ed.sheet, S::WithdrawContainers { index: 0 });
2747 ed.sheet_back();
2748 assert_eq!(ed.sheet, S::AddMenu { index: 0 });
2749 ed.sheet_back();
2750 assert_eq!(ed.sheet, S::Stops);
2751 ed.sheet_back();
2753 assert_eq!(ed.sheet, S::Stops);
2754 }
2755
2756 #[test]
2757 fn sheet_back_while_editing_returns_to_stops() {
2758 use RouteEditorSheet as S;
2759 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2760 ed.append_harvest_node("oak-a");
2761 ed.begin_edit_selected();
2762 ed.open_sheet(S::HarvestPicker {
2763 index: 0,
2764 picked: BTreeSet::new(),
2765 nodes: Vec::new(),
2766 });
2767 ed.sheet_back();
2768 assert_eq!(ed.sheet, S::Stops);
2769 assert_eq!(ed.editing_index, None);
2770 }
2771
2772 #[test]
2773 fn sheet_back_while_editing_withdraw_keeps_edit_on_container_picker() {
2774 use RouteEditorSheet as S;
2775 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2776 ed.stops.push(WorkerRouteStop::WithdrawFrom {
2777 container_id: "chest-a".into(),
2778 items: vec![WorkerRouteWithdrawItem {
2779 template: "oak_log".into(),
2780 qty: None,
2781 }],
2782 });
2783 ed.begin_edit_selected();
2784 ed.open_sheet(S::WithdrawItems {
2785 container_id: "chest-a".into(),
2786 lines: vec![],
2787 index: 0,
2788 });
2789 ed.sheet_back();
2790 assert!(matches!(ed.sheet, S::WithdrawContainers { .. }));
2791 assert_eq!(
2792 ed.editing_index,
2793 Some(0),
2794 "still editing after back to picker"
2795 );
2796 ed.sheet_back();
2797 assert_eq!(ed.sheet, S::Stops);
2798 assert_eq!(ed.editing_index, None);
2799 }
2800
2801 #[test]
2802 fn confirm_stop_replaces_withdraw_container_when_editing() {
2803 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2804 ed.stops.push(WorkerRouteStop::WithdrawFrom {
2805 container_id: "chest-old".into(),
2806 items: vec![WorkerRouteWithdrawItem {
2807 template: "oak_log".into(),
2808 qty: None,
2809 }],
2810 });
2811 ed.stops.push(WorkerRouteStop::RestIfNeeded);
2812 ed.select_stop(0);
2813 ed.begin_edit_selected();
2814 assert!(ed.confirm_stop(WorkerRouteStop::WithdrawFrom {
2815 container_id: "chest-new".into(),
2816 items: vec![WorkerRouteWithdrawItem {
2817 template: "oak_log".into(),
2818 qty: None,
2819 }],
2820 }));
2821 assert_eq!(ed.stops.len(), 2);
2822 assert!(matches!(
2823 &ed.stops[0],
2824 WorkerRouteStop::WithdrawFrom { container_id, .. } if container_id == "chest-new"
2825 ));
2826 }
2827
2828 #[test]
2829 fn confirm_stop_replaces_when_editing() {
2830 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2831 ed.append_harvest_node("oak-a");
2832 ed.append_waypoint(1.0, 1.0, 0.0);
2833 ed.select_stop(0);
2834 ed.begin_edit_selected();
2835 assert!(ed.confirm_stop(WorkerRouteStop::HarvestNode {
2836 node_id: "oak-b".into()
2837 }));
2838 assert_eq!(ed.stops.len(), 2, "edit replaces in place, no append");
2839 assert!(
2840 matches!(&ed.stops[0], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
2841 );
2842 assert_eq!(ed.sheet, RouteEditorSheet::Stops);
2843 assert_eq!(ed.editing_index, None);
2844 }
2845
2846 #[test]
2847 fn confirm_stop_dedupes_on_append() {
2848 let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2849 ed.append_harvest_node("oak-a");
2850 assert!(!ed.confirm_stop(WorkerRouteStop::HarvestNode {
2851 node_id: "oak-a".into()
2852 }));
2853 assert_eq!(ed.stops.len(), 1);
2854 assert_eq!(ed.selected_stop_index, 0);
2855 }
2856
2857 #[test]
2858 fn withdraw_line_cycle_and_collect() {
2859 let contents = vec![
2860 ItemStack {
2861 template_id: "oak_log".into(),
2862 quantity: 12,
2863 ..Default::default()
2864 },
2865 ItemStack {
2866 template_id: "lumber".into(),
2867 quantity: 4,
2868 ..Default::default()
2869 },
2870 ];
2871 let mut lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &[]);
2872 assert_eq!(lines.len(), 2);
2873 lines[1].cycle();
2875 assert_eq!(lines[1].mode, WithdrawLineMode::All);
2876 lines[0].cycle();
2877 lines[0].cycle();
2878 assert!(matches!(lines[0].mode, WithdrawLineMode::Qty(_)));
2879 lines[0].adjust_qty(5);
2880 let items = WorkerRouteEditorState::withdraw_items_from_lines(&lines);
2881 assert_eq!(items.len(), 2);
2882 assert_eq!(items[0].template, "lumber");
2883 assert_eq!(items[0].qty, Some(4));
2885 assert_eq!(items[1].qty, None);
2886 }
2887
2888 #[test]
2889 fn withdraw_drafts_prefill_existing_and_keep_missing() {
2890 let contents = vec![ItemStack {
2891 template_id: "oak_log".into(),
2892 quantity: 3,
2893 ..Default::default()
2894 }];
2895 let existing = vec![
2896 WorkerRouteWithdrawItem {
2897 template: "oak_log".into(),
2898 qty: None,
2899 },
2900 WorkerRouteWithdrawItem {
2901 template: "iron_ore".into(),
2902 qty: Some(5),
2903 },
2904 ];
2905 let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
2906 assert_eq!(lines.len(), 2);
2907 let ore = lines
2908 .iter()
2909 .find(|l| l.template == "iron_ore")
2910 .expect("ore line");
2911 assert_eq!(ore.available, 0, "missing template kept with 0 available");
2912 assert_eq!(ore.mode, WithdrawLineMode::Qty(5));
2913 let oak = lines
2914 .iter()
2915 .find(|l| l.template == "oak_log")
2916 .expect("oak line");
2917 assert_eq!(oak.mode, WithdrawLineMode::All);
2918 }
2919}