Skip to main content

flatland_client_lib/
worker_route_editor.rs

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