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