Skip to main content

flatland_client_lib/
worker_route_editor.rs

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