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