Skip to main content

flatland_client_lib/
worker_route_editor.rs

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