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