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/// True when a resource node can be assigned as a worker harvest-route stop.
562pub fn is_harvest_route_node(n: &ResourceNodeView) -> bool {
563    !n.harvest_off
564        && !n.id.starts_with("preview:")
565        && !n.id.starts_with("carcass-")
566        && !n.id.contains("::")
567}
568
569fn node_candidate_label(n: &ResourceNodeView) -> String {
570    let base = crate::resource_node_route_label(n);
571    match n.state {
572        flatland_protocol::ResourceNodeState::Cooldown => format!("{base} (depleted)"),
573        flatland_protocol::ResourceNodeState::Harvesting => format!("{base} (harvesting)"),
574        flatland_protocol::ResourceNodeState::Available => base,
575    }
576}
577
578/// One resource-node row for the harvest picker.
579#[derive(Debug, Clone, PartialEq)]
580pub struct NodeCandidate {
581    pub id: String,
582    pub label: String,
583    pub template: String,
584    pub dist: f32,
585}
586
587/// Harvestable resource nodes sorted by distance to `anchor_x` / `anchor_y` (rest bed).
588/// Includes depleted/cooldown nodes so a route can wait for regen; skips neighbor
589/// preview ghosts and carcasses.
590pub fn node_candidates(
591    nodes: &[ResourceNodeView],
592    anchor_x: f32,
593    anchor_y: f32,
594) -> Vec<NodeCandidate> {
595    let mut out: Vec<NodeCandidate> = nodes
596        .iter()
597        .filter(|n| is_harvest_route_node(n))
598        .map(|n| NodeCandidate {
599            id: n.id.clone(),
600            label: node_candidate_label(n),
601            template: n.item_template.clone(),
602            dist: dist2d(anchor_x, anchor_y, n.x, n.y),
603        })
604        .collect();
605    out.sort_by(|a, b| {
606        a.dist
607            .partial_cmp(&b.dist)
608            .unwrap_or(std::cmp::Ordering::Equal)
609            .then_with(|| a.label.cmp(&b.label))
610            .then_with(|| a.id.cmp(&b.id))
611    });
612    out
613}
614
615/// When no rest bed is set, keep a stable order (label, id) so the picker does not jump.
616pub fn node_candidates_stable(nodes: &[ResourceNodeView]) -> Vec<NodeCandidate> {
617    let mut out: Vec<NodeCandidate> = nodes
618        .iter()
619        .filter(|n| is_harvest_route_node(n))
620        .map(|n| NodeCandidate {
621            id: n.id.clone(),
622            label: node_candidate_label(n),
623            template: n.item_template.clone(),
624            dist: f32::NAN,
625        })
626        .collect();
627    out.sort_by(|a, b| a.label.cmp(&b.label).then_with(|| a.id.cmp(&b.id)));
628    out
629}
630
631/// Rest-bed world position for the open route editor (if a bed is chosen).
632pub fn route_editor_lodging_anchor(
633    lodging_container_id: Option<&str>,
634    placed: &[PlacedContainerView],
635) -> Option<(f32, f32)> {
636    let id = lodging_container_id?;
637    placed.iter().find(|c| c.id == id).map(|c| (c.x, c.y))
638}
639
640/// First row in multi-select harvest / sell sheets — always **Done**.
641pub const ROUTE_PICKER_DONE_ROW: usize = 0;
642/// Sell sheet: row 1 is the sell-all toggle (row 0 = Done).
643pub const SELL_ITEM_TOGGLE_ROW: usize = 1;
644
645pub fn harvest_picker_row_count(nodes_len: usize) -> usize {
646    nodes_len + 1
647}
648
649pub fn sell_item_picker_row_count(templates_len: usize) -> usize {
650    templates_len + 2
651}
652
653/// Filter helper for harvest picker rows (row 0 = Done, rows 1.. = `nodes[row - 1]`).
654pub fn harvest_picker_row_matches(nodes: &[NodeCandidate], row: usize, filter: &str) -> bool {
655    if row == ROUTE_PICKER_DONE_ROW {
656        return true;
657    }
658    let slot = row - 1;
659    nodes.get(slot).is_some_and(|n| {
660        let dist = n.dist.is_finite().then_some(n.dist);
661        list_filter_row_matches(filter, dist, &[&n.label, &n.template, &n.id])
662    })
663}
664
665/// One merchant row for the sell picker.
666#[derive(Debug, Clone, PartialEq)]
667pub struct TradeNpcCandidate {
668    pub id: String,
669    pub label: String,
670    pub dist: f32,
671    /// False when this merchant buys none of the current route item candidates.
672    pub buys_route_item: bool,
673}
674
675/// True when `npc` buys at least one template in `route_templates`.
676pub fn merchant_buys_any_route_item(npc: &NpcView, route_templates: &[String]) -> bool {
677    if route_templates.is_empty() {
678        return false;
679    }
680    route_templates
681        .iter()
682        .any(|t| npc.buy_templates.iter().any(|b| b == t))
683}
684
685/// True when any trade-capable NPC buys at least one route item candidate.
686pub fn any_trade_npc_buys_route_item(npcs: &[NpcView], route_templates: &[String]) -> bool {
687    npcs.iter()
688        .filter(|n| n.can_trade)
689        .any(|n| merchant_buys_any_route_item(n, route_templates))
690}
691
692/// Human reason when sellable candidates are empty for `npc_id` (`None` = auto).
693pub fn sell_merchant_empty_reason(
694    npc_id: Option<&str>,
695    npcs: &[NpcView],
696    route_templates: &[String],
697) -> String {
698    let items = if route_templates.is_empty() {
699        "your route items".to_string()
700    } else {
701        route_templates.join("/")
702    };
703    match npc_id {
704        Some(id) => {
705            let Some(npc) = npcs.iter().find(|n| n.id == id) else {
706                return format!("Route: merchant {id} not found");
707            };
708            let name = if npc.label.is_empty() {
709                npc.id.as_str()
710            } else {
711                npc.label.as_str()
712            };
713            if npc.buy_templates.is_empty() {
714                format!("Route: {name} has an empty buy list — they don't buy any items")
715            } else {
716                format!("Route: {name} doesn't buy any of your route items ({items})")
717            }
718        }
719        None => {
720            if !any_trade_npc_buys_route_item(npcs, route_templates) {
721                format!(
722                    "Route: no trade NPC buys any of your route items ({items})"
723                )
724            } else {
725                format!("Route: no sellable item templates for nearest buyer ({items})")
726            }
727        }
728    }
729}
730
731/// Trade-capable NPCs sorted by distance to the player.
732///
733/// `route_templates` drives [`TradeNpcCandidate::buys_route_item`] so the sell
734/// picker can disable merchants with no overlapping buys.
735pub fn trade_npc_candidates(
736    npcs: &[NpcView],
737    px: f32,
738    py: f32,
739    route_templates: &[String],
740) -> Vec<TradeNpcCandidate> {
741    let mut out: Vec<TradeNpcCandidate> = npcs
742        .iter()
743        .filter(|n| n.can_trade)
744        .map(|n| TradeNpcCandidate {
745            id: n.id.clone(),
746            label: n.label.clone(),
747            dist: dist2d(px, py, n.x, n.y),
748            buys_route_item: merchant_buys_any_route_item(n, route_templates),
749        })
750        .collect();
751    out.sort_by(|a, b| {
752        // Prefer merchants who can buy something from this route, then distance.
753        b.buys_route_item
754            .cmp(&a.buys_route_item)
755            .then_with(|| {
756                a.dist
757                    .partial_cmp(&b.dist)
758                    .unwrap_or(std::cmp::Ordering::Equal)
759            })
760            .then_with(|| a.id.cmp(&b.id))
761    });
762    out
763}
764
765// ---- editor state ---------------------------------------------------
766
767/// Draft route for a hired worker. `stops` is the ordered, editable list;
768/// `lodging_container_id` is the bed used for `RestIfNeeded`.
769#[derive(Debug, Clone)]
770pub struct WorkerRouteEditorState {
771    pub worker_instance_id: String,
772    pub worker_label: String,
773    pub lodging_container_id: Option<String>,
774    /// Ordered, player-authored stops. May be empty until the player adds some.
775    pub stops: Vec<WorkerRouteStop>,
776    /// Selected stop index for reorder / delete operations.
777    pub selected_stop_index: usize,
778    /// Carry threshold passed to each `HarvestNode` stop's compiled `HarvestRoute`.
779    pub carry_return_ratio: f32,
780    /// Active sheet (sub-screen). `Stops` is the root.
781    pub sheet: RouteEditorSheet,
782    /// When set, confirming a sheet **replaces** this stop instead of appending
783    /// (Enter on a stop = edit it in place).
784    pub editing_index: Option<usize>,
785    /// When true, only a thin bar is drawn so the map stays visible for clicks.
786    pub panel_collapsed: bool,
787    /// `/` search string for the active picker sheet.
788    pub sheet_filter: String,
789    pub sheet_filter_focused: bool,
790}
791
792impl WorkerRouteEditorState {
793    pub fn new(
794        worker_instance_id: String,
795        worker_label: String,
796        lodging_container_id: Option<String>,
797    ) -> Self {
798        Self {
799            worker_instance_id,
800            worker_label,
801            lodging_container_id,
802            stops: Vec::new(),
803            selected_stop_index: 0,
804            carry_return_ratio: 0.90,
805            sheet: RouteEditorSheet::Stops,
806            editing_index: None,
807            panel_collapsed: false,
808            sheet_filter: String::new(),
809            sheet_filter_focused: false,
810        }
811    }
812
813    pub fn toggle_panel_collapsed(&mut self) {
814        self.panel_collapsed = !self.panel_collapsed;
815    }
816
817    pub fn from_saved_route(
818        worker_instance_id: String,
819        worker_label: String,
820        route: &WorkerRouteView,
821        lodging_fallback: Option<String>,
822    ) -> Self {
823        let lodging = route.lodging_container_id.clone().or(lodging_fallback);
824
825        match route.kind {
826            WorkerRouteKindView::Ordered => Self {
827                worker_instance_id,
828                worker_label,
829                lodging_container_id: lodging,
830                stops: route.stops.iter().map(stop_view_to_stop).collect(),
831                selected_stop_index: 0,
832                carry_return_ratio: route.carry_return_ratio,
833                sheet: RouteEditorSheet::Stops,
834                editing_index: None,
835                panel_collapsed: false,
836                sheet_filter: String::new(),
837                sheet_filter_focused: false,
838            },
839            WorkerRouteKindView::HarvestLoop => {
840                // Convert legacy harvest_loop shape into an ordered stop list so
841                // the editor presents one unified model. Outbound waypoints and
842                // harvest nodes are interleaved in declaration order (waypoints
843                // first, then harvest, then deposit at lodging, then rest).
844                let mut stops = Vec::new();
845                for wp in &route.outbound_waypoints {
846                    stops.push(WorkerRouteStop::Waypoint {
847                        x: wp.x,
848                        y: wp.y,
849                        z: wp.z,
850                    });
851                }
852                for node in &route.harvest_nodes {
853                    stops.push(WorkerRouteStop::HarvestNode {
854                        node_id: node.clone(),
855                    });
856                }
857                if let Some(lodging) = &lodging {
858                    stops.push(WorkerRouteStop::DepositAt {
859                        container_id: lodging.clone(),
860                        filter: None,
861                    });
862                    stops.push(WorkerRouteStop::RestIfNeeded);
863                }
864                Self {
865                    worker_instance_id,
866                    worker_label,
867                    lodging_container_id: lodging,
868                    stops,
869                    selected_stop_index: 0,
870                    carry_return_ratio: route.carry_return_ratio,
871                    sheet: RouteEditorSheet::Stops,
872                    editing_index: None,
873                    panel_collapsed: false,
874                    sheet_filter: String::new(),
875                    sheet_filter_focused: false,
876                }
877            }
878        }
879    }
880
881    // ---- stop list editing ------------------------------------------
882
883    pub fn stop_count(&self) -> usize {
884        self.stops.len()
885    }
886
887    pub fn select_stop(&mut self, index: usize) {
888        if self.stops.is_empty() {
889            self.selected_stop_index = 0;
890            return;
891        }
892        self.selected_stop_index = index.min(self.stops.len() - 1);
893    }
894
895    pub fn move_selected_up(&mut self) {
896        if self.selected_stop_index == 0 {
897            return;
898        }
899        self.stops
900            .swap(self.selected_stop_index, self.selected_stop_index - 1);
901        self.selected_stop_index -= 1;
902    }
903
904    pub fn move_selected_down(&mut self) {
905        if self.selected_stop_index + 1 >= self.stops.len() {
906            return;
907        }
908        self.stops
909            .swap(self.selected_stop_index, self.selected_stop_index + 1);
910        self.selected_stop_index += 1;
911    }
912
913    pub fn remove_selected_stop(&mut self) {
914        if self.stops.is_empty() {
915            return;
916        }
917        let idx = self.selected_stop_index.min(self.stops.len() - 1);
918        self.stops.remove(idx);
919        self.editing_index = None;
920        if self.selected_stop_index >= self.stops.len() {
921            self.selected_stop_index = self.stops.len().saturating_sub(1);
922        }
923    }
924
925    /// Index of the first stop matching `pred`, if any.
926    fn find_stop(&self, pred: impl Fn(&WorkerRouteStop) -> bool) -> Option<usize> {
927        self.stops.iter().position(pred)
928    }
929
930    pub fn harvest_node_index(&self, node_id: &str) -> Option<usize> {
931        self.find_stop(|s| matches!(s, WorkerRouteStop::HarvestNode { node_id: n } if n == node_id))
932    }
933
934    pub fn deposit_container_index(&self, container_id: &str) -> Option<usize> {
935        self.find_stop(
936            |s| matches!(s, WorkerRouteStop::DepositAt { container_id: c, .. } if c == container_id),
937        )
938    }
939
940    pub fn trade_stop_index(&self, npc_id: Option<&str>, template: &str) -> Option<usize> {
941        self.find_stop(|s| {
942            matches!(s, WorkerRouteStop::TradeWith { npc_id: n, template: t, .. }
943                if n.as_deref() == npc_id && t == template)
944        })
945    }
946
947    /// Insert a stop, deduping targets that must not repeat (harvest nodes,
948    /// deposit containers, identical sell stops): a duplicate selects the
949    /// existing stop instead of appending. Returns (appended, index).
950    pub fn insert_stop(&mut self, stop: WorkerRouteStop) -> (bool, usize) {
951        let existing = match &stop {
952            WorkerRouteStop::HarvestNode { node_id } => self.harvest_node_index(node_id),
953            WorkerRouteStop::DepositAt { container_id, .. } => {
954                self.deposit_container_index(container_id)
955            }
956            WorkerRouteStop::TradeWith {
957                npc_id, template, ..
958            } => self.trade_stop_index(npc_id.as_deref(), template),
959            _ => None,
960        };
961        if let Some(idx) = existing {
962            self.selected_stop_index = idx;
963            return (false, idx);
964        }
965        self.stops.push(stop);
966        self.selected_stop_index = self.stops.len() - 1;
967        (true, self.stops.len() - 1)
968    }
969
970    pub fn append_waypoint(&mut self, x: f32, y: f32, z: f32) {
971        self.insert_stop(WorkerRouteStop::Waypoint { x, y, z });
972    }
973
974    pub fn append_harvest_node(&mut self, node_id: &str) -> bool {
975        self.insert_stop(WorkerRouteStop::HarvestNode {
976            node_id: node_id.to_string(),
977        })
978        .0
979    }
980
981    pub fn append_deposit_at(&mut self, container_id: &str) -> bool {
982        self.insert_stop(WorkerRouteStop::DepositAt {
983            container_id: container_id.to_string(),
984            filter: None,
985        })
986        .0
987    }
988
989    /// Append a filtered deposit (deposit only `filter_templates`, keep everything else —
990    /// e.g. keep the worker's handsaw while depositing lumber).
991    pub fn append_deposit_at_filtered(
992        &mut self,
993        container_id: &str,
994        filter_templates: Vec<String>,
995    ) {
996        self.stops.push(WorkerRouteStop::DepositAt {
997            container_id: container_id.to_string(),
998            filter: Some(filter_templates),
999        });
1000        self.selected_stop_index = self.stops.len() - 1;
1001    }
1002
1003    pub fn append_rest_if_needed(&mut self) {
1004        self.insert_stop(WorkerRouteStop::RestIfNeeded);
1005    }
1006
1007    pub fn append_wait(&mut self, wait_ticks: u64) {
1008        self.insert_stop(WorkerRouteStop::Wait { wait_ticks });
1009    }
1010
1011    /// Append a `TradeWith` stop — sell `template` (whole stack) to `npc_id`
1012    /// (`None` = auto-pick the nearest NPC that buys it). An identical sell
1013    /// stop (same merchant + template) is selected instead of duplicated.
1014    pub fn append_trade_with(
1015        &mut self,
1016        template: String,
1017        npc_id: Option<String>,
1018        sell_all: bool,
1019    ) -> bool {
1020        self.insert_stop(WorkerRouteStop::TradeWith {
1021            npc_id,
1022            template,
1023            sell_all,
1024        })
1025        .0
1026    }
1027
1028    /// Pin the selected stop's NPC when the player clicks a merchant on the map.
1029    /// Only affects `TradeWith` stops; returns true if a stop was updated.
1030    /// If pinning would duplicate another sell stop (same merchant + template),
1031    /// the selected stop is removed and the existing one is selected instead.
1032    pub fn set_selected_trade_npc(&mut self, npc_id: String) -> bool {
1033        let Some(stop) = self.stops.get_mut(self.selected_stop_index) else {
1034            return false;
1035        };
1036        let WorkerRouteStop::TradeWith {
1037            npc_id: slot,
1038            template,
1039            ..
1040        } = stop
1041        else {
1042            return false;
1043        };
1044        *slot = Some(npc_id.clone());
1045        let template = template.clone();
1046        let selected = self.selected_stop_index;
1047        if let Some(other) = self
1048            .trade_stop_index(Some(npc_id.as_str()), template.as_str())
1049            .filter(|&i| i != selected)
1050        {
1051            self.stops.remove(selected);
1052            self.selected_stop_index = if other > selected { other - 1 } else { other };
1053        }
1054        true
1055    }
1056
1057    /// Retarget the selected stop's source container when the player clicks a
1058    /// chest on the map. Only affects `WithdrawFrom` stops; returns true if a
1059    /// stop was updated.
1060    pub fn set_selected_withdraw_container(&mut self, container_id: String) -> bool {
1061        let Some(stop) = self.stops.get_mut(self.selected_stop_index) 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 withdraw
1075    /// chest. Prefer `editing_index` when set.
1076    pub fn retarget_withdraw_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::WithdrawFrom {
1082            container_id: slot, ..
1083        } = stop
1084        {
1085            *slot = container_id;
1086            return true;
1087        }
1088        false
1089    }
1090
1091    /// Retarget the stop currently being edited (or selected) to a new deposit
1092    /// chest. Prefer `editing_index` when set.
1093    pub fn retarget_deposit_container(&mut self, container_id: String) -> bool {
1094        let idx = self.editing_index.unwrap_or(self.selected_stop_index);
1095        let Some(stop) = self.stops.get_mut(idx) else {
1096            return false;
1097        };
1098        if let WorkerRouteStop::DepositAt {
1099            container_id: slot, ..
1100        } = stop
1101        {
1102            *slot = container_id;
1103            return true;
1104        }
1105        false
1106    }
1107
1108    // ---- sheet navigation --------------------------------------------
1109
1110    pub fn open_add_menu(&mut self) {
1111        self.editing_index = None;
1112        self.sheet = RouteEditorSheet::AddMenu { index: 0 };
1113    }
1114
1115    pub fn open_sheet(&mut self, sheet: RouteEditorSheet) {
1116        self.sheet_filter.clear();
1117        self.sheet_filter_focused = false;
1118        self.sheet = sheet;
1119    }
1120
1121    /// Append harvest stops for every picked node (edit replaces first, rest append).
1122    pub fn confirm_harvest_picks(&mut self, node_ids: &[String]) -> usize {
1123        if node_ids.is_empty() {
1124            return 0;
1125        }
1126        let mut added = 0usize;
1127        if let Some(idx) = self.editing_index.take() {
1128            if let Some(first) = node_ids.first() {
1129                if idx < self.stops.len() {
1130                    self.stops[idx] = WorkerRouteStop::HarvestNode {
1131                        node_id: first.clone(),
1132                    };
1133                    self.selected_stop_index = idx;
1134                    added = 1;
1135                }
1136                for id in node_ids.iter().skip(1) {
1137                    if self
1138                        .insert_stop(WorkerRouteStop::HarvestNode {
1139                            node_id: id.clone(),
1140                        })
1141                        .0
1142                    {
1143                        added += 1;
1144                    }
1145                }
1146            }
1147        } else {
1148            for id in node_ids {
1149                if self
1150                    .insert_stop(WorkerRouteStop::HarvestNode {
1151                        node_id: id.clone(),
1152                    })
1153                    .0
1154                {
1155                    added += 1;
1156                }
1157            }
1158        }
1159        self.sheet = RouteEditorSheet::Stops;
1160        added
1161    }
1162
1163    /// Append sell stops for every picked template (same merchant + sell-all flag).
1164    pub fn confirm_trade_picks(
1165        &mut self,
1166        npc_id: Option<String>,
1167        templates: &[String],
1168        sell_all: bool,
1169    ) -> usize {
1170        if templates.is_empty() {
1171            return 0;
1172        }
1173        let mut added = 0usize;
1174        if let Some(idx) = self.editing_index.take() {
1175            if let Some(first) = templates.first() {
1176                if idx < self.stops.len() {
1177                    self.stops[idx] = WorkerRouteStop::TradeWith {
1178                        npc_id: npc_id.clone(),
1179                        template: first.clone(),
1180                        sell_all,
1181                    };
1182                    self.selected_stop_index = idx;
1183                    added = 1;
1184                }
1185                for template in templates.iter().skip(1) {
1186                    if self
1187                        .insert_stop(WorkerRouteStop::TradeWith {
1188                            npc_id: npc_id.clone(),
1189                            template: template.clone(),
1190                            sell_all,
1191                        })
1192                        .0
1193                    {
1194                        added += 1;
1195                    }
1196                }
1197            }
1198        } else {
1199            for template in templates {
1200                if self
1201                    .insert_stop(WorkerRouteStop::TradeWith {
1202                        npc_id: npc_id.clone(),
1203                        template: template.clone(),
1204                        sell_all,
1205                    })
1206                    .0
1207                {
1208                    added += 1;
1209                }
1210            }
1211        }
1212        self.sheet = RouteEditorSheet::Stops;
1213        added
1214    }
1215
1216    /// Append market-list stops for every picked template (NPC-price hall listing).
1217    pub fn confirm_market_list_picks(
1218        &mut self,
1219        hall_id: Option<String>,
1220        templates: &[String],
1221        list_all: bool,
1222    ) -> usize {
1223        if templates.is_empty() {
1224            return 0;
1225        }
1226        let mut added = 0usize;
1227        if let Some(idx) = self.editing_index.take() {
1228            if let Some(first) = templates.first() {
1229                if idx < self.stops.len() {
1230                    self.stops[idx] = WorkerRouteStop::ListOnMarket {
1231                        template: first.clone(),
1232                        list_all,
1233                        hall_id: hall_id.clone(),
1234                    };
1235                    self.selected_stop_index = idx;
1236                    added = 1;
1237                }
1238                for template in templates.iter().skip(1) {
1239                    if self
1240                        .insert_stop(WorkerRouteStop::ListOnMarket {
1241                            template: template.clone(),
1242                            list_all,
1243                            hall_id: hall_id.clone(),
1244                        })
1245                        .0
1246                    {
1247                        added += 1;
1248                    }
1249                }
1250            }
1251        } else {
1252            for template in templates {
1253                if self
1254                    .insert_stop(WorkerRouteStop::ListOnMarket {
1255                        template: template.clone(),
1256                        list_all,
1257                        hall_id: hall_id.clone(),
1258                    })
1259                    .0
1260                {
1261                    added += 1;
1262                }
1263            }
1264        }
1265        self.sheet = RouteEditorSheet::Stops;
1266        added
1267    }
1268
1269    /// Mark the selected stop as being edited; the next `confirm_stop` replaces
1270    /// it in place. Caller then opens the matching sheet (prefilled).
1271    pub fn begin_edit_selected(&mut self) {
1272        if self.selected_stop_index < self.stops.len() {
1273            self.editing_index = Some(self.selected_stop_index);
1274        }
1275    }
1276
1277    /// Esc pops one sheet level. When editing, Esc from an items/filter sheet
1278    /// returns to the container picker (still editing); Esc from the picker
1279    /// cancels the edit. Root (`Stops`) is a no-op — the caller closes the editor.
1280    pub fn sheet_back(&mut self) {
1281        use RouteEditorSheet as S;
1282        let editing = self.editing_index.is_some();
1283        let next = match &self.sheet {
1284            S::Stops => return,
1285            S::AddMenu { .. } | S::BedPicker { .. } | S::FarmPlotPicker { .. } => S::Stops,
1286            S::FarmPlantSeed { .. } => S::FarmPlotPicker {
1287                index: 0,
1288                action: FarmPlotAction::Plant,
1289            },
1290            S::WaypointMapPick => {
1291                if editing {
1292                    S::Stops
1293                } else {
1294                    S::WaypointMenu { index: 0 }
1295                }
1296            }
1297            // While editing, back out of items/filter to the chest/NPC picker
1298            // so the player can retarget without canceling the whole edit.
1299            S::WithdrawItems { .. } => S::WithdrawContainers { index: 0 },
1300            S::DepositFilter { .. } => S::DepositContainers { index: 0 },
1301            S::SellItem { .. } => S::SellNpcs { index: 0 },
1302            S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
1303                if editing =>
1304            {
1305                S::Stops
1306            }
1307            // Top-level sheets: back to the Add menu (or Stops when editing).
1308            _ => {
1309                if editing {
1310                    S::Stops
1311                } else {
1312                    S::AddMenu { index: 0 }
1313                }
1314            }
1315        };
1316        if matches!(next, S::Stops) {
1317            self.editing_index = None;
1318        }
1319        self.sheet = next;
1320    }
1321
1322    /// Confirm the current sheet's stop: replace the edited stop in place, or
1323    /// append (deduped). Returns true when a stop was appended/replaced and
1324    /// false when a duplicate selected the existing stop instead.
1325    pub fn confirm_stop(&mut self, stop: WorkerRouteStop) -> bool {
1326        let result = if let Some(idx) = self.editing_index.take() {
1327            if idx < self.stops.len() {
1328                self.stops[idx] = stop;
1329                self.selected_stop_index = idx;
1330            }
1331            true
1332        } else {
1333            self.insert_stop(stop).0
1334        };
1335        self.sheet = RouteEditorSheet::Stops;
1336        result
1337    }
1338
1339    /// Build withdraw line drafts for `container_id` from its contents.
1340    /// `existing` pre-fills modes (editing an existing stop); templates no
1341    /// longer present are kept with `available: 0`.
1342    pub fn withdraw_line_drafts(
1343        contents: &[ItemStack],
1344        existing: &[WorkerRouteWithdrawItem],
1345    ) -> Vec<WithdrawLineDraft> {
1346        let mut lines: Vec<WithdrawLineDraft> = Vec::new();
1347        for s in contents {
1348            if s.template_id.is_empty() {
1349                continue;
1350            }
1351            match lines.iter_mut().find(|l| l.template == s.template_id) {
1352                Some(l) => l.available += s.quantity,
1353                None => lines.push(WithdrawLineDraft {
1354                    template: s.template_id.clone(),
1355                    available: s.quantity,
1356                    mode: WithdrawLineMode::Off,
1357                }),
1358            }
1359        }
1360        for item in existing {
1361            let mode = match item.qty {
1362                None => WithdrawLineMode::All,
1363                Some(q) => WithdrawLineMode::Qty(q),
1364            };
1365            match lines.iter_mut().find(|l| l.template == item.template) {
1366                Some(l) => l.mode = mode,
1367                None => lines.push(WithdrawLineDraft {
1368                    template: item.template.clone(),
1369                    available: 0,
1370                    mode,
1371                }),
1372            }
1373        }
1374        lines.sort_by(|a, b| a.template.cmp(&b.template));
1375        lines
1376    }
1377
1378    /// Collect the active withdraw lines into stop items (`None` = all).
1379    pub fn withdraw_items_from_lines(lines: &[WithdrawLineDraft]) -> Vec<WorkerRouteWithdrawItem> {
1380        lines
1381            .iter()
1382            .filter_map(|l| match l.mode {
1383                WithdrawLineMode::Off => None,
1384                WithdrawLineMode::All => Some(WorkerRouteWithdrawItem {
1385                    template: l.template.clone(),
1386                    qty: None,
1387                }),
1388                WithdrawLineMode::Qty(q) => Some(WorkerRouteWithdrawItem {
1389                    template: l.template.clone(),
1390                    qty: Some(q),
1391                }),
1392            })
1393            .collect()
1394    }
1395
1396    // ---- YAML emission -----------------------------------------------
1397
1398    fn job_id(&self) -> String {
1399        format!(
1400            "route_{}",
1401            self.worker_instance_id
1402                .chars()
1403                .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
1404                .collect::<String>()
1405        )
1406    }
1407
1408    /// YAML that parks the worker: `mode: idle` with no steps. Saving an empty
1409    /// route means "stand down" rather than leaving the worker in a broken
1410    /// job loop.
1411    pub fn build_idle_job_yaml(&self) -> String {
1412        // No `route` block — an empty ordered route is rejected server-side,
1413        // and `mode: idle` with no steps is the explicit "stand down" signal.
1414        let job_id = self.job_id();
1415        [
1416            format!("job_id: {job_id}"),
1417            "mode: idle".into(),
1418            "steps: []".into(),
1419        ]
1420        .join("\n")
1421    }
1422
1423    pub fn build_job_yaml(&self) -> Result<String, String> {
1424        if self.stops.is_empty() {
1425            return Err("add at least one stop (waypoint, harvest node, or deposit)".into());
1426        }
1427        let job_id = self.job_id();
1428        let mut lines = vec![
1429            format!("job_id: {job_id}"),
1430            "mode: job_loop".into(),
1431            "route:".into(),
1432            "  kind: ordered".into(),
1433        ];
1434        if let Some(lodging) = &self.lodging_container_id {
1435            lines.push(format!("  lodging_container_id: {lodging}"));
1436        }
1437        lines.push(format!(
1438            "  carry_return_ratio: {:.2}",
1439            self.carry_return_ratio
1440        ));
1441        lines.push("  stops:".into());
1442        for stop in &self.stops {
1443            match stop {
1444                WorkerRouteStop::Waypoint { x, y, z } => {
1445                    lines.push(format!(
1446                        "    - {{ stop: waypoint, x: {:.1}, y: {:.1}, z: {:.1} }}",
1447                        x, y, z
1448                    ));
1449                }
1450                WorkerRouteStop::HarvestNode { node_id } => {
1451                    lines.push(format!(
1452                        "    - {{ stop: harvest_node, node_id: {node_id} }}"
1453                    ));
1454                }
1455                WorkerRouteStop::DepositAt {
1456                    container_id,
1457                    filter,
1458                } => {
1459                    let f = filter
1460                        .as_ref()
1461                        .filter(|f| !f.is_empty())
1462                        .map(|f| {
1463                            format!(
1464                                ", filter: [{}]",
1465                                f.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
1466                            )
1467                        })
1468                        .unwrap_or_default();
1469                    lines.push(format!(
1470                        "    - {{ stop: deposit_at, container_id: {container_id}{f} }}"
1471                    ));
1472                }
1473                WorkerRouteStop::RestIfNeeded => {
1474                    lines.push("    - { stop: rest_if_needed }".into());
1475                }
1476                WorkerRouteStop::Wait { wait_ticks } => {
1477                    lines.push(format!("    - {{ stop: wait, wait_ticks: {wait_ticks} }}"));
1478                }
1479                WorkerRouteStop::TradeWith {
1480                    npc_id,
1481                    template,
1482                    sell_all,
1483                } => {
1484                    let who = npc_id
1485                        .as_deref()
1486                        .map(|n| format!(", npc_id: {n}"))
1487                        .unwrap_or_default();
1488                    lines.push(format!(
1489                        "    - {{ stop: trade_with, template: {template}{who}, sell_all: {sell_all} }}"
1490                    ));
1491                }
1492                WorkerRouteStop::ListOnMarket {
1493                    template,
1494                    list_all,
1495                    hall_id,
1496                } => {
1497                    let hall = hall_id
1498                        .as_deref()
1499                        .map(|h| format!(", hall_id: {h}"))
1500                        .unwrap_or_default();
1501                    lines.push(format!(
1502                        "    - {{ stop: list_on_market, template: {template}{hall}, list_all: {list_all} }}"
1503                    ));
1504                }
1505                WorkerRouteStop::WithdrawFrom {
1506                    container_id,
1507                    items,
1508                } => {
1509                    let mut block = format!(
1510                        "    - stop: withdraw_from\n      container_id: {container_id}\n      items:"
1511                    );
1512                    for it in items {
1513                        let line = match it.qty {
1514                            None => {
1515                                format!("\n        - {{ template: {}, all: true }}", it.template)
1516                            }
1517                            Some(q) => {
1518                                format!("\n        - {{ template: {}, qty: {} }}", it.template, q)
1519                            }
1520                        };
1521                        block.push_str(&line);
1522                    }
1523                    lines.push(block);
1524                }
1525                WorkerRouteStop::CraftAt {
1526                    device,
1527                    blueprint,
1528                    qty,
1529                } => {
1530                    let qty_str = qty.map(|q| format!(", qty: {q}")).unwrap_or_default();
1531                    lines.push(format!(
1532                        "    - {{ stop: craft_at, device: {device}, blueprint: {blueprint}{qty_str} }}"
1533                    ));
1534                }
1535                WorkerRouteStop::CultivatePlot { plot_id } => {
1536                    lines.push(format!(
1537                        "    - {{ stop: cultivate_plot, plot_id: \"{plot_id}\" }}"
1538                    ));
1539                }
1540                WorkerRouteStop::PlantPlot {
1541                    plot_id,
1542                    seed_template,
1543                } => {
1544                    lines.push(format!(
1545                        "    - {{ stop: plant_plot, plot_id: \"{plot_id}\", seed_template: {seed_template} }}"
1546                    ));
1547                }
1548                WorkerRouteStop::HarvestPlot { plot_id } => {
1549                    lines.push(format!(
1550                        "    - {{ stop: harvest_plot, plot_id: \"{plot_id}\" }}"
1551                    ));
1552                }
1553            }
1554        }
1555        lines.push("steps: []".into());
1556        Ok(lines.join("\n"))
1557    }
1558
1559    /// Build a protocol route view matching the current draft (for optimistic client UI).
1560    pub fn to_route_view(&self) -> flatland_protocol::WorkerRouteView {
1561        use flatland_protocol::{
1562            WorkerRouteKindView, WorkerRouteStopView, WorkerRouteView, WorkerWithdrawItemView,
1563        };
1564        WorkerRouteView {
1565            kind: WorkerRouteKindView::Ordered,
1566            lodging_container_id: self.lodging_container_id.clone(),
1567            outbound_waypoints: Vec::new(),
1568            harvest_nodes: Vec::new(),
1569            carry_return_ratio: self.carry_return_ratio,
1570            stops: self
1571                .stops
1572                .iter()
1573                .map(|stop| match stop {
1574                    WorkerRouteStop::Waypoint { x, y, z } => WorkerRouteStopView::Waypoint {
1575                        x: *x,
1576                        y: *y,
1577                        z: *z,
1578                    },
1579                    WorkerRouteStop::HarvestNode { node_id } => WorkerRouteStopView::HarvestNode {
1580                        node_id: node_id.clone(),
1581                    },
1582                    WorkerRouteStop::DepositAt {
1583                        container_id,
1584                        filter,
1585                    } => WorkerRouteStopView::DepositAt {
1586                        container_id: container_id.clone(),
1587                        filter: filter.clone(),
1588                    },
1589                    WorkerRouteStop::TradeWith {
1590                        npc_id,
1591                        template,
1592                        sell_all,
1593                    } => WorkerRouteStopView::TradeWith {
1594                        npc_id: npc_id.clone(),
1595                        template: template.clone(),
1596                        sell_all: *sell_all,
1597                    },
1598                    WorkerRouteStop::ListOnMarket {
1599                        template,
1600                        list_all,
1601                        hall_id,
1602                    } => WorkerRouteStopView::ListOnMarket {
1603                        template: template.clone(),
1604                        list_all: *list_all,
1605                        hall_id: hall_id.clone(),
1606                    },
1607                    WorkerRouteStop::WithdrawFrom {
1608                        container_id,
1609                        items,
1610                    } => WorkerRouteStopView::WithdrawFrom {
1611                        container_id: container_id.clone(),
1612                        items: items
1613                            .iter()
1614                            .map(|i| WorkerWithdrawItemView {
1615                                template: i.template.clone(),
1616                                qty: i.qty.unwrap_or(0),
1617                                all: i.qty.is_none(),
1618                            })
1619                            .collect(),
1620                    },
1621                    WorkerRouteStop::CraftAt {
1622                        device,
1623                        blueprint,
1624                        qty,
1625                    } => WorkerRouteStopView::CraftAt {
1626                        device: device.clone(),
1627                        blueprint: blueprint.clone(),
1628                        qty: *qty,
1629                    },
1630                    WorkerRouteStop::CultivatePlot { plot_id } => {
1631                        WorkerRouteStopView::CultivatePlot { plot_id: *plot_id }
1632                    }
1633                    WorkerRouteStop::PlantPlot {
1634                        plot_id,
1635                        seed_template,
1636                    } => WorkerRouteStopView::PlantPlot {
1637                        plot_id: *plot_id,
1638                        seed_template: seed_template.clone(),
1639                    },
1640                    WorkerRouteStop::HarvestPlot { plot_id } => {
1641                        WorkerRouteStopView::HarvestPlot { plot_id: *plot_id }
1642                    }
1643                    WorkerRouteStop::RestIfNeeded => WorkerRouteStopView::RestIfNeeded,
1644                    WorkerRouteStop::Wait { wait_ticks } => WorkerRouteStopView::Wait {
1645                        wait_ticks: *wait_ticks,
1646                    },
1647                })
1648                .collect(),
1649        }
1650    }
1651}
1652
1653fn stop_view_to_stop(view: &WorkerRouteStopView) -> WorkerRouteStop {
1654    match view {
1655        WorkerRouteStopView::Waypoint { x, y, z } => WorkerRouteStop::Waypoint {
1656            x: *x,
1657            y: *y,
1658            z: *z,
1659        },
1660        WorkerRouteStopView::HarvestNode { node_id } => WorkerRouteStop::HarvestNode {
1661            node_id: node_id.clone(),
1662        },
1663        WorkerRouteStopView::DepositAt {
1664            container_id,
1665            filter,
1666        } => WorkerRouteStop::DepositAt {
1667            container_id: container_id.clone(),
1668            filter: filter.clone(),
1669        },
1670        WorkerRouteStopView::TradeWith {
1671            npc_id,
1672            template,
1673            sell_all,
1674        } => WorkerRouteStop::TradeWith {
1675            npc_id: npc_id.clone(),
1676            template: template.clone(),
1677            sell_all: *sell_all,
1678        },
1679        WorkerRouteStopView::ListOnMarket {
1680            template,
1681            list_all,
1682            hall_id,
1683        } => WorkerRouteStop::ListOnMarket {
1684            template: template.clone(),
1685            list_all: *list_all,
1686            hall_id: hall_id.clone(),
1687        },
1688        WorkerRouteStopView::WithdrawFrom {
1689            container_id,
1690            items,
1691        } => WorkerRouteStop::WithdrawFrom {
1692            container_id: container_id.clone(),
1693            items: items
1694                .iter()
1695                .map(|i| WorkerRouteWithdrawItem {
1696                    template: i.template.clone(),
1697                    qty: if i.all { None } else { Some(i.qty) },
1698                })
1699                .collect(),
1700        },
1701        WorkerRouteStopView::CraftAt {
1702            device,
1703            blueprint,
1704            qty,
1705        } => WorkerRouteStop::CraftAt {
1706            device: device.clone(),
1707            blueprint: blueprint.clone(),
1708            qty: *qty,
1709        },
1710        WorkerRouteStopView::CultivatePlot { plot_id } => {
1711            WorkerRouteStop::CultivatePlot { plot_id: *plot_id }
1712        }
1713        WorkerRouteStopView::PlantPlot {
1714            plot_id,
1715            seed_template,
1716        } => WorkerRouteStop::PlantPlot {
1717            plot_id: *plot_id,
1718            seed_template: seed_template.clone(),
1719        },
1720        WorkerRouteStopView::HarvestPlot { plot_id } => {
1721            WorkerRouteStop::HarvestPlot { plot_id: *plot_id }
1722        }
1723        WorkerRouteStopView::RestIfNeeded => WorkerRouteStop::RestIfNeeded,
1724        WorkerRouteStopView::Wait { wait_ticks } => WorkerRouteStop::Wait {
1725            wait_ticks: *wait_ticks,
1726        },
1727    }
1728}
1729
1730// ---- map-click picking ----------------------------------------------
1731
1732const HARVEST_NODE_PICK_M: f32 = 4.0;
1733const LODGING_PICK_M: f32 = 5.0;
1734const STORAGE_PICK_M: f32 = 5.0;
1735
1736fn dist2d(x0: f32, y0: f32, x1: f32, y1: f32) -> f32 {
1737    let dx = x0 - x1;
1738    let dy = y0 - y1;
1739    (dx * dx + dy * dy).sqrt()
1740}
1741
1742/// Nearest harvestable resource node within click range.
1743pub fn pick_resource_node_at<'a>(
1744    nodes: &'a [ResourceNodeView],
1745    x: f32,
1746    y: f32,
1747) -> Option<&'a ResourceNodeView> {
1748    nodes
1749        .iter()
1750        .filter(|n| is_harvest_route_node(n))
1751        .filter_map(|n| {
1752            let d = dist2d(x, y, n.x, n.y);
1753            if d <= HARVEST_NODE_PICK_M {
1754                Some((d, n))
1755            } else {
1756                None
1757            }
1758        })
1759        .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1760        .map(|(_, n)| n)
1761}
1762
1763/// Owned placed lodging containers (camp beds) — used as the rest/bed target.
1764pub fn owned_lodging_container_ids(
1765    placed: &[PlacedContainerView],
1766    character_id: Option<uuid::Uuid>,
1767) -> Vec<(String, String)> {
1768    owned_lodging_container_ids_with_occupants(placed, character_id, &[])
1769}
1770
1771/// Lodging beds with display names that include occupant labels (`Camp bed — Elda`).
1772pub fn owned_lodging_container_ids_with_occupants(
1773    placed: &[PlacedContainerView],
1774    character_id: Option<uuid::Uuid>,
1775    hired: &[flatland_protocol::HiredWorkerView],
1776) -> Vec<(String, String)> {
1777    let Some(cid) = character_id else {
1778        return Vec::new();
1779    };
1780    let mut out: Vec<(String, String)> = placed
1781        .iter()
1782        .filter(|c| c.worker_lodging_capacity.unwrap_or(0) > 0)
1783        .filter(|c| c.owner_character_id == Some(cid))
1784        .map(|c| {
1785            let who = lodging_occupants_for(hired, &c.id);
1786            let name = if who.is_empty() {
1787                format!("{} — vacant", c.display_name)
1788            } else {
1789                format!("{} — {}", c.display_name, who.join(", "))
1790            };
1791            (c.id.clone(), name)
1792        })
1793        .collect();
1794    out.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
1795    out
1796}
1797
1798pub fn pick_lodging_container_at(
1799    placed: &[PlacedContainerView],
1800    character_id: Option<uuid::Uuid>,
1801    x: f32,
1802    y: f32,
1803    observer_inside: Option<&str>,
1804) -> Option<String> {
1805    let cid = character_id?;
1806    placed
1807        .iter()
1808        .filter(|c| container_in_observer_space(c, observer_inside))
1809        .filter(|c| c.worker_lodging_capacity.unwrap_or(0) > 0)
1810        .filter(|c| c.owner_character_id == Some(cid))
1811        .filter_map(|c| {
1812            let d = dist2d(x, y, c.x, c.y);
1813            if d <= LODGING_PICK_M {
1814                Some((d, c.id.clone()))
1815            } else {
1816                None
1817            }
1818        })
1819        .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1820        .map(|(_, id)| id)
1821}
1822
1823/// Nearest trade-capable NPC within click range.
1824pub fn pick_trade_npc_at(npcs: &[NpcView], x: f32, y: f32) -> Option<(String, String)> {
1825    const NPC_PICK_M: f32 = 5.0;
1826    npcs.iter()
1827        .filter(|n| n.can_trade)
1828        .filter_map(|n| {
1829            let d = dist2d(x, y, n.x, n.y);
1830            if d <= NPC_PICK_M {
1831                Some((d, n.id.clone(), n.label.clone()))
1832            } else {
1833                None
1834            }
1835        })
1836        .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1837        .map(|(_, id, label)| (id, label))
1838}
1839
1840/// Distinct item templates present in the employer's owned placed containers —
1841/// candidate sell templates for `TradeWith` stops.
1842pub fn owned_storage_template_ids(
1843    placed: &[PlacedContainerView],
1844    character_id: Option<uuid::Uuid>,
1845) -> Vec<String> {
1846    let Some(cid) = character_id else {
1847        return Vec::new();
1848    };
1849    let mut out: Vec<String> = placed
1850        .iter()
1851        .filter(|c| c.owner_character_id == Some(cid))
1852        .flat_map(|c| c.contents.iter().map(|s| s.template_id.clone()))
1853        .filter(|t| !t.is_empty())
1854        .collect();
1855    out.sort();
1856    out.dedup();
1857    out
1858}
1859
1860/// Blueprint ids the route editor may assign to a `craft_at hand` stop.
1861///
1862/// Order matches `blueprints`. When the worker has a non-empty
1863/// `known_blueprint_ids` list, only those recipes are included — the picker UI
1864/// and j/k navigation must use the same list so the cursor cannot land on
1865/// recipes the worker does not know (and so Enter confirms the highlighted row).
1866pub fn worker_craft_blueprint_ids(
1867    blueprints: &[flatland_protocol::BlueprintView],
1868    known_blueprint_ids: Option<&[String]>,
1869) -> Vec<String> {
1870    let mut ids: Vec<String> = blueprints.iter().map(|b| b.id.clone()).collect();
1871    if let Some(known) = known_blueprint_ids {
1872        if !known.is_empty() {
1873            ids.retain(|id| known.iter().any(|k| k == id));
1874        }
1875    }
1876    ids
1877}
1878
1879/// Item templates for deposit filters / sell stops — not limited to what is
1880/// already in a chest. Includes storage, inventory, craft outputs/inputs, harvest
1881/// *drops* (not harvest_node world templates), and any `extra` seeds (existing
1882/// filter selections, route craft outputs) so factory lines can be authored
1883/// before the first batch is made.
1884pub fn route_item_template_candidates(
1885    placed: &[PlacedContainerView],
1886    character_id: Option<uuid::Uuid>,
1887    inventory: &std::collections::HashMap<String, u32>,
1888    blueprints: &[flatland_protocol::BlueprintView],
1889    resource_nodes: &[flatland_protocol::ResourceNodeView],
1890    extra: &[String],
1891    catalog: Option<&std::collections::HashMap<String, ItemCatalogEntryView>>,
1892) -> Vec<String> {
1893    let mut out = owned_storage_template_ids(placed, character_id);
1894    for (template, qty) in inventory {
1895        if *qty > 0 && !template.is_empty() {
1896            out.push(template.clone());
1897        }
1898    }
1899    for bp in blueprints {
1900        if !bp.output.is_empty() {
1901            out.push(bp.output.clone());
1902        }
1903        for input in &bp.inputs {
1904            if !input.template_id.is_empty() {
1905                out.push(input.template_id.clone());
1906            }
1907        }
1908        for tool in &bp.required_tools {
1909            if !tool.item.is_empty() {
1910                out.push(tool.item.clone());
1911            }
1912        }
1913    }
1914    for node in resource_nodes {
1915        // `item_template` is the world harvest_node (Rocks, oak tree) — not a stack.
1916        for t in &node.harvest_drop_templates {
1917            if !t.is_empty() {
1918                out.push(t.clone());
1919            }
1920        }
1921    }
1922    for t in extra {
1923        if !t.is_empty() {
1924            out.push(t.clone());
1925        }
1926    }
1927    out.sort();
1928    out.dedup();
1929    if let Some(catalog) = catalog {
1930        out.retain(|id| catalog.get(id).is_none_or(|e| e.is_depositable_stack()));
1931    }
1932    out
1933}
1934
1935/// Filter broad route templates to exact items accepted by the selected merchant.
1936///
1937/// `npc_id: None` means any trade-capable NPC may buy the item. This is separate
1938/// from [`route_item_template_candidates`] because deposit filters intentionally
1939/// include inputs, tools, and harvest-node templates that are not sellable.
1940pub fn sellable_route_item_template_candidates(
1941    templates: &[String],
1942    npcs: &[flatland_protocol::NpcView],
1943    npc_id: Option<&str>,
1944) -> Vec<String> {
1945    let accepted = npcs
1946        .iter()
1947        .filter(|npc| npc_id.is_none_or(|id| npc.id == id))
1948        .filter(|npc| npc_id.is_some() || npc.can_trade)
1949        .flat_map(|npc| npc.buy_templates.iter().map(String::as_str))
1950        .collect::<std::collections::HashSet<_>>();
1951    let mut out: Vec<String> = templates
1952        .iter()
1953        .filter(|template| accepted.contains(template.as_str()))
1954        .cloned()
1955        .collect();
1956    out.sort();
1957    out.dedup();
1958    out
1959}
1960
1961/// Pick any owned storage container at a click position (lodging or regular chest).
1962/// Only same-space chests (outdoor vs interior) are clickable — owned indoor
1963/// chests may still be listed for route planning while outdoors.
1964pub fn pick_storage_container_at(
1965    placed: &[PlacedContainerView],
1966    character_id: Option<uuid::Uuid>,
1967    x: f32,
1968    y: f32,
1969    observer_inside: Option<&str>,
1970) -> Option<String> {
1971    let cid = character_id?;
1972    placed
1973        .iter()
1974        .filter(|c| container_in_observer_space(c, observer_inside))
1975        .filter(|c| c.owner_character_id == Some(cid))
1976        .filter(|c| c.capacity_volume.unwrap_or(0.0) > 0.0)
1977        .filter_map(|c| {
1978            let d = dist2d(x, y, c.x, c.y);
1979            if d <= STORAGE_PICK_M {
1980                Some((d, c.id.clone()))
1981            } else {
1982                None
1983            }
1984        })
1985        .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1986        .map(|(_, id)| id)
1987}
1988
1989fn container_in_observer_space(c: &PlacedContainerView, observer_inside: Option<&str>) -> bool {
1990    match (observer_inside, c.building_id.as_deref()) {
1991        (None, None) => true,
1992        (Some(a), Some(b)) => a == b,
1993        _ => false,
1994    }
1995}
1996
1997#[cfg(test)]
1998mod tests {
1999    use super::*;
2000
2001    #[test]
2002    fn worker_craft_blueprint_ids_filters_to_known_recipes() {
2003        use flatland_protocol::{BlueprintIngredientView, BlueprintView};
2004
2005        fn bp(id: &str) -> BlueprintView {
2006            BlueprintView {
2007                id: id.into(),
2008                label: id.into(),
2009                output: "x".into(),
2010                output_qty: 1,
2011                craft_ticks: 1,
2012                inputs: vec![BlueprintIngredientView {
2013                    template_id: "oak_log".into(),
2014                    quantity: 1,
2015                    consumed: true,
2016                    display_name: "Oak Log".into(),
2017                }],
2018                station: Some("hand".into()),
2019                category: None,
2020                required_tools: vec![],
2021                skill: None,
2022                failure_chance: 0.0,
2023                worker_train_copper: 0,
2024                output_display_name: "X".into(),
2025                craft_tier: 1,
2026            }
2027        }
2028
2029        let all = vec![
2030            bp("oak_to_lumber"),
2031            bp("vegetable_soup"),
2032            bp("craft_simple_camp_bed"),
2033            bp("craft_wooden_chest_small"),
2034            bp("iron_ingot"),
2035        ];
2036        let known = vec![
2037            "oak_to_lumber".into(),
2038            "craft_simple_camp_bed".into(),
2039            "craft_wooden_chest_small".into(),
2040        ];
2041
2042        let filtered = worker_craft_blueprint_ids(&all, Some(&known));
2043        assert_eq!(
2044            filtered,
2045            vec![
2046                "oak_to_lumber",
2047                "craft_simple_camp_bed",
2048                "craft_wooden_chest_small"
2049            ]
2050        );
2051
2052        // Empty known list = no filter (legacy / unknown worker).
2053        assert_eq!(worker_craft_blueprint_ids(&all, Some(&[])).len(), all.len());
2054        assert_eq!(worker_craft_blueprint_ids(&all, None).len(), all.len());
2055    }
2056
2057    #[test]
2058    fn route_item_candidates_include_craft_outputs_not_in_storage() {
2059        use flatland_protocol::{
2060            BlueprintIngredientView, BlueprintView, PlacedContainerView, ResourceNodeState,
2061            ResourceNodeView,
2062        };
2063        use std::collections::HashMap;
2064        use uuid::Uuid;
2065
2066        let cid = Uuid::from_u128(0x1111_2222_3333_4444_5555_6666_7777_8888);
2067        let placed = vec![PlacedContainerView {
2068            id: "chest-1".into(),
2069            template_id: "wooden_chest_small".into(),
2070            display_name: "Chest".into(),
2071            x: 0.0,
2072            y: 0.0,
2073            z: 0.0,
2074            locked: false,
2075            accessible: true,
2076            owner_character_id: Some(cid),
2077            contents: vec![],
2078            lock_id: None,
2079            capacity_volume: Some(20.0),
2080            item_instance_id: None,
2081            tile_id: None,
2082            worker_lodging_capacity: None,
2083            blocking: false,
2084            blocking_radius_m: 0.0,
2085            building_id: None,
2086        }];
2087        let blueprints = vec![BlueprintView {
2088            id: "smelt_iron".into(),
2089            label: "Smelt Iron".into(),
2090            output: "iron_ingot".into(),
2091            output_qty: 1,
2092            craft_ticks: 30,
2093            inputs: vec![BlueprintIngredientView {
2094                template_id: "iron_ore".into(),
2095                quantity: 1,
2096                consumed: true,
2097                display_name: "Iron Ore".into(),
2098            }],
2099            station: Some("hand".into()),
2100            category: None,
2101            required_tools: vec![],
2102            skill: None,
2103            failure_chance: 0.0,
2104            worker_train_copper: 0,
2105            output_display_name: "Iron Ingot".into(),
2106            craft_tier: 1,
2107        }];
2108        let nodes = vec![ResourceNodeView {
2109            id: "ore-1".into(),
2110            label: "Iron Ore".into(),
2111            x: 1.0,
2112            y: 1.0,
2113            z: 0.0,
2114            item_template: "iron_ore".into(),
2115            state: ResourceNodeState::Available,
2116            blocking: true,
2117            blocking_radius_m: 0.8,
2118            harvest_off: false,
2119            tile_id: None,
2120            yaw: 0.0,
2121            pitch: 0.0,
2122            roll: 0.0,
2123            draw_scale: 1.0,
2124            sprite_mode: None,
2125            growth_progress: None,
2126            presentation_state: None,
2127            channel_start_tick: None,
2128            channel_end_tick: None,
2129            harvest_drop_templates: vec![],
2130        }];
2131        let ids = route_item_template_candidates(
2132            &placed,
2133            Some(cid),
2134            &HashMap::new(),
2135            &blueprints,
2136            &nodes,
2137            &[],
2138            None,
2139        );
2140        assert!(
2141            ids.contains(&"iron_ingot".to_string()),
2142            "craft output should be selectable before any exists in storage: {ids:?}"
2143        );
2144        assert!(ids.contains(&"iron_ore".to_string()));
2145    }
2146
2147    #[test]
2148    fn sellable_route_candidates_follow_npc_buy_lists() {
2149        use flatland_protocol::NpcView;
2150
2151        fn npc(id: &str, can_trade: bool, buy_templates: &[&str]) -> NpcView {
2152            NpcView {
2153                id: id.into(),
2154                label: id.into(),
2155                role: "merchant".into(),
2156                x: 0.0,
2157                y: 0.0,
2158                building_id: None,
2159                entity_id: None,
2160                life_state: None,
2161                hp_pct: None,
2162                can_trade,
2163                buy_templates: buy_templates.iter().map(|t| (*t).into()).collect(),
2164                tile_id: None,
2165                behavior_state: None,
2166                presentation_state: None,
2167                sprite_mode: None,
2168                paperdoll_ref: None,
2169                draw_scale: 1.0,
2170                yaw: None,
2171                perception_fov_deg: None,
2172                perception_sight_m: None,
2173                perception_hear_m: None,
2174                quest_verbs: Vec::new(),
2175            }
2176        }
2177
2178        let templates = vec![
2179            "carrot".into(),
2180            "carrot_wild".into(),
2181            "carrot_seed".into(),
2182            "oak_log".into(),
2183        ];
2184        let npcs = vec![
2185            npc("maris", true, &["carrot"]),
2186            npc("eli", true, &["carrot_seed"]),
2187            npc("wildlife", false, &["oak_log"]),
2188        ];
2189
2190        assert_eq!(
2191            sellable_route_item_template_candidates(&templates, &npcs, Some("maris")),
2192            vec!["carrot"]
2193        );
2194        assert_eq!(
2195            sellable_route_item_template_candidates(&templates, &npcs, None),
2196            vec!["carrot", "carrot_seed"]
2197        );
2198    }
2199
2200    #[test]
2201    fn trade_npc_candidates_mark_non_overlapping_merchants() {
2202        use flatland_protocol::NpcView;
2203
2204        fn npc(id: &str, label: &str, can_trade: bool, buy_templates: &[&str], x: f32) -> NpcView {
2205            NpcView {
2206                id: id.into(),
2207                label: label.into(),
2208                role: "merchant".into(),
2209                x,
2210                y: 0.0,
2211                building_id: None,
2212                entity_id: None,
2213                life_state: None,
2214                hp_pct: None,
2215                can_trade,
2216                buy_templates: buy_templates.iter().map(|t| (*t).into()).collect(),
2217                tile_id: None,
2218                behavior_state: None,
2219                presentation_state: None,
2220                sprite_mode: None,
2221                paperdoll_ref: None,
2222                draw_scale: 1.0,
2223                yaw: None,
2224                perception_fov_deg: None,
2225                perception_sight_m: None,
2226                perception_hear_m: None,
2227                quest_verbs: Vec::new(),
2228            }
2229        }
2230
2231        let route = vec!["carrot".into(), "potato".into()];
2232        let npcs = vec![
2233            npc("mira_market", "Mira", true, &[], 10.0),
2234            npc("ada_broker", "Ada", true, &["lumber", "oak_log"], 5.0),
2235            npc("maris_cook", "Maris", true, &["carrot"], 20.0),
2236            npc("wildlife", "Wolf", false, &["carrot"], 1.0),
2237        ];
2238        let cands = trade_npc_candidates(&npcs, 0.0, 0.0, &route);
2239        assert_eq!(cands.len(), 3, "only can_trade NPCs: {cands:?}");
2240        assert!(cands[0].buys_route_item && cands[0].id == "maris_cook");
2241        assert!(!cands.iter().any(|c| c.id == "wildlife"));
2242        let ada = cands.iter().find(|c| c.id == "ada_broker").unwrap();
2243        assert!(!ada.buys_route_item);
2244        let mira = cands.iter().find(|c| c.id == "mira_market").unwrap();
2245        assert!(!mira.buys_route_item);
2246        assert!(any_trade_npc_buys_route_item(&npcs, &route));
2247        assert!(!any_trade_npc_buys_route_item(
2248            &npcs,
2249            &["iron_ingot".into()]
2250        ));
2251        assert!(sell_merchant_empty_reason(Some("mira_market"), &npcs, &route)
2252            .contains("empty buy list"));
2253        assert!(sell_merchant_empty_reason(Some("ada_broker"), &npcs, &route)
2254            .contains("doesn't buy any of your route items"));
2255        assert!(sell_merchant_empty_reason(None, &npcs, &["iron_ingot".into()])
2256            .contains("no trade NPC buys"));
2257    }
2258
2259    #[test]
2260    fn route_item_template_includes_harvest_loot_table_drops() {
2261        use flatland_protocol::{ResourceNodeState, ResourceNodeView};
2262        use std::collections::HashMap;
2263        let nodes = vec![ResourceNodeView {
2264            id: "crop-carrot-1".into(),
2265            label: "Wild carrots".into(),
2266            x: 1.0,
2267            y: 1.0,
2268            z: 0.0,
2269            item_template: "carrot_wild".into(),
2270            state: ResourceNodeState::Available,
2271            blocking: false,
2272            blocking_radius_m: 0.8,
2273            harvest_off: false,
2274            tile_id: None,
2275            yaw: 0.0,
2276            pitch: 0.0,
2277            roll: 0.0,
2278            draw_scale: 1.0,
2279            sprite_mode: None,
2280            growth_progress: None,
2281            presentation_state: None,
2282            channel_start_tick: None,
2283            channel_end_tick: None,
2284            harvest_drop_templates: vec!["carrot".into(), "carrot_seed".into()],
2285        }];
2286        let ids =
2287            route_item_template_candidates(&[], None, &HashMap::new(), &[], &nodes, &[], None);
2288        assert!(
2289            ids.contains(&"carrot_seed".to_string()),
2290            "deposit filter should list seeds from harvest loot tables: {ids:?}"
2291        );
2292        assert!(
2293            !ids.contains(&"carrot_wild".to_string()),
2294            "harvest_node templates are not deposit stacks: {ids:?}"
2295        );
2296    }
2297
2298    #[test]
2299    fn route_item_candidates_drop_harvest_node_catalog_entries() {
2300        use flatland_protocol::{ResourceNodeState, ResourceNodeView};
2301        use std::collections::HashMap;
2302        let rocks = "3ee51931-189c-4726-828b-dffb1a3d1fc4";
2303        let stone = "592fe396-cc6e-42d8-8554-4080c3b19036";
2304        let nodes = vec![ResourceNodeView {
2305            id: "rocks-1".into(),
2306            label: "Rocks".into(),
2307            x: 1.0,
2308            y: 1.0,
2309            z: 0.0,
2310            item_template: rocks.into(),
2311            state: ResourceNodeState::Available,
2312            blocking: false,
2313            blocking_radius_m: 0.8,
2314            harvest_off: false,
2315            tile_id: None,
2316            yaw: 0.0,
2317            pitch: 0.0,
2318            roll: 0.0,
2319            draw_scale: 1.0,
2320            sprite_mode: None,
2321            growth_progress: None,
2322            presentation_state: None,
2323            channel_start_tick: None,
2324            channel_end_tick: None,
2325            harvest_drop_templates: vec![stone.into()],
2326        }];
2327        let mut catalog = HashMap::new();
2328        catalog.insert(
2329            rocks.to_string(),
2330            ItemCatalogEntryView {
2331                template_id: rocks.into(),
2332                display_name: "Rocks".into(),
2333                category: "harvest_node".into(),
2334                seed_for: None,
2335            },
2336        );
2337        catalog.insert(
2338            stone.to_string(),
2339            ItemCatalogEntryView {
2340                template_id: stone.into(),
2341                display_name: "Rough Stone".into(),
2342                category: "resource".into(),
2343                seed_for: None,
2344            },
2345        );
2346        let ids = route_item_template_candidates(
2347            &[],
2348            None,
2349            &HashMap::new(),
2350            &[],
2351            &nodes,
2352            &[rocks.into()],
2353            Some(&catalog),
2354        );
2355        assert!(
2356            ids.contains(&stone.to_string()),
2357            "loot drops stay selectable: {ids:?}"
2358        );
2359        assert!(
2360            !ids.contains(&rocks.to_string()),
2361            "Rocks harvest_node must not appear in deposit filter: {ids:?}"
2362        );
2363    }
2364
2365    #[test]
2366    fn build_ordered_job_yaml_includes_stops() {
2367        let mut ed = WorkerRouteEditorState::new(
2368            "worker-worker_laborer-1".into(),
2369            "Laborer".into(),
2370            Some("chest-bed".into()),
2371        );
2372        ed.append_waypoint(10.0, 20.0, 0.0);
2373        ed.append_harvest_node("oak-n1");
2374        ed.append_deposit_at("chest-storage-a");
2375        ed.append_rest_if_needed();
2376        let yaml = ed.build_job_yaml().expect("yaml");
2377        assert!(yaml.contains("kind: ordered"));
2378        assert!(yaml.contains("lodging_container_id: chest-bed"));
2379        assert!(yaml.contains("stop: waypoint"));
2380        assert!(yaml.contains("oak-n1"));
2381        assert!(yaml.contains("deposit_at"));
2382        assert!(yaml.contains("chest-storage-a"));
2383        assert!(yaml.contains("rest_if_needed"));
2384    }
2385
2386    #[test]
2387    fn requires_at_least_one_stop() {
2388        let ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2389        assert!(ed.build_job_yaml().is_err());
2390    }
2391
2392    #[test]
2393    fn reorder_stops() {
2394        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2395        ed.append_harvest_node("oak-a");
2396        ed.append_harvest_node("oak-b");
2397        ed.append_waypoint(5.0, 6.0, 0.0);
2398        // select index 1 (oak-b), move up → order becomes oak-b, oak-a, waypoint
2399        ed.select_stop(1);
2400        ed.move_selected_up();
2401        assert!(
2402            matches!(&ed.stops[0], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
2403        );
2404        // move down again restores
2405        ed.move_selected_down();
2406        assert!(
2407            matches!(&ed.stops[1], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
2408        );
2409    }
2410
2411    #[test]
2412    fn duplicate_harvest_node_selects_existing_instead() {
2413        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2414        assert!(ed.append_harvest_node("oak-a"));
2415        ed.append_waypoint(1.0, 2.0, 0.0);
2416        assert!(!ed.append_harvest_node("oak-a"));
2417        assert_eq!(ed.stops.len(), 2);
2418        assert_eq!(ed.selected_stop_index, 0);
2419    }
2420
2421    #[test]
2422    fn duplicate_deposit_container_selects_existing_instead() {
2423        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2424        assert!(ed.append_deposit_at("chest-1"));
2425        ed.append_harvest_node("oak-a");
2426        assert!(!ed.append_deposit_at("chest-1"));
2427        assert_eq!(ed.stops.len(), 2);
2428        assert_eq!(ed.selected_stop_index, 0);
2429    }
2430
2431    #[test]
2432    fn duplicate_trade_stop_selects_existing_instead() {
2433        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2434        assert!(ed.append_trade_with("oak_log".into(), Some("ada".into()), true));
2435        assert!(!ed.append_trade_with("oak_log".into(), Some("ada".into()), true));
2436        // Different template at the same merchant is a distinct stop.
2437        assert!(ed.append_trade_with("lumber".into(), Some("ada".into()), true));
2438        assert_eq!(ed.stops.len(), 2);
2439    }
2440
2441    #[test]
2442    fn build_idle_job_yaml_parks_worker() {
2443        let ed = WorkerRouteEditorState::new("w1".into(), "L".into(), Some("bed-1".into()));
2444        let yaml = ed.build_idle_job_yaml();
2445        assert!(yaml.contains("mode: idle"));
2446        assert!(yaml.contains("steps: []"));
2447        assert!(!yaml.contains("route:"));
2448    }
2449
2450    #[test]
2451    fn remove_selected_stop_adjusts_index() {
2452        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2453        ed.append_waypoint(1.0, 2.0, 0.0);
2454        ed.append_harvest_node("oak-a");
2455        ed.append_deposit_at("chest-1");
2456        ed.select_stop(2);
2457        ed.remove_selected_stop();
2458        assert_eq!(ed.stops.len(), 2);
2459        assert_eq!(ed.selected_stop_index, 1);
2460    }
2461
2462    #[test]
2463    fn build_job_yaml_includes_trade_with_stop() {
2464        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2465        ed.append_trade_with("oak_log".into(), None, true);
2466        ed.append_trade_with("lumber".into(), Some("ada_broker".into()), false);
2467        let yaml = ed.build_job_yaml().expect("yaml");
2468        assert!(yaml.contains("stop: trade_with, template: oak_log, sell_all: true"));
2469        assert!(yaml.contains("npc_id: ada_broker"));
2470        assert!(yaml.contains("sell_all: false"));
2471    }
2472
2473    #[test]
2474    fn build_job_yaml_includes_list_on_market_stop() {
2475        let mut ed = WorkerRouteEditorState::new("w1".into(), "Laborer".into(), None);
2476        let _ = ed.insert_stop(WorkerRouteStop::ListOnMarket {
2477            template: "carrot".into(),
2478            list_all: true,
2479            hall_id: Some("town_market".into()),
2480        });
2481        let yaml = ed.build_job_yaml().expect("yaml");
2482        assert!(yaml.contains("list_on_market"), "{yaml}");
2483        assert!(yaml.contains("template: carrot"), "{yaml}");
2484        assert!(yaml.contains("hall_id: town_market"), "{yaml}");
2485        assert!(yaml.contains("list_all: true"), "{yaml}");
2486    }
2487
2488
2489    #[test]
2490    fn set_selected_trade_npc_updates_stop() {
2491        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2492        ed.append_trade_with("oak_log".into(), None, true);
2493        assert!(ed.set_selected_trade_npc("ada_broker".into()));
2494        assert!(
2495            matches!(&ed.stops[0], WorkerRouteStop::TradeWith { npc_id, .. } if npc_id.as_deref() == Some("ada_broker"))
2496        );
2497    }
2498
2499    #[test]
2500    fn build_job_yaml_deposit_filter_round_trips() {
2501        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), Some("bed-1".into()));
2502        ed.append_deposit_at_filtered("chest-out", vec!["lumber".into()]);
2503        let yaml = ed.build_job_yaml().expect("yaml");
2504        assert!(yaml.contains("stop: deposit_at, container_id: chest-out, filter: [lumber]"));
2505    }
2506
2507    #[test]
2508    fn build_job_yaml_includes_withdraw_and_craft_stops() {
2509        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2510        ed.stops.push(WorkerRouteStop::WithdrawFrom {
2511            container_id: "chest-src".into(),
2512            items: vec![WorkerRouteWithdrawItem {
2513                template: "oak_log".into(),
2514                qty: None,
2515            }],
2516        });
2517        ed.stops.push(WorkerRouteStop::WithdrawFrom {
2518            container_id: "chest-src-2".into(),
2519            items: vec![WorkerRouteWithdrawItem {
2520                template: "iron_ore".into(),
2521                qty: Some(10),
2522            }],
2523        });
2524        ed.stops.push(WorkerRouteStop::CraftAt {
2525            device: "hand".into(),
2526            blueprint: "oak_to_lumber".into(),
2527            qty: None,
2528        });
2529        ed.append_deposit_at("chest-out");
2530        let yaml = ed.build_job_yaml().expect("yaml");
2531        assert!(yaml.contains("stop: withdraw_from"));
2532        assert!(yaml.contains("container_id: chest-src"));
2533        assert!(yaml.contains("template: oak_log, all: true"));
2534        assert!(yaml.contains("template: iron_ore, qty: 10"));
2535        assert!(yaml.contains("stop: craft_at, device: hand, blueprint: oak_to_lumber"));
2536        assert!(yaml.contains("stop: deposit_at"));
2537    }
2538
2539    #[test]
2540    fn withdraw_summary_shows_all_vs_qty() {
2541        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2542        ed.stops.push(WorkerRouteStop::WithdrawFrom {
2543            container_id: "chest-src".into(),
2544            items: vec![WorkerRouteWithdrawItem {
2545                template: "oak_log".into(),
2546                qty: None,
2547            }],
2548        });
2549        assert!(ed.stops[0].summary().contains("withdraw all oak_log"));
2550    }
2551
2552    #[test]
2553    fn withdraw_view_round_trips_all_flag() {
2554        let view = WorkerRouteStopView::WithdrawFrom {
2555            container_id: "chest-1".into(),
2556            items: vec![
2557                flatland_protocol::WorkerWithdrawItemView {
2558                    template: "oak_log".into(),
2559                    qty: 0,
2560                    all: true,
2561                },
2562                flatland_protocol::WorkerWithdrawItemView {
2563                    template: "iron_ore".into(),
2564                    qty: 5,
2565                    all: false,
2566                },
2567            ],
2568        };
2569        let stop = stop_view_to_stop(&view);
2570        let WorkerRouteStop::WithdrawFrom { items, .. } = stop else {
2571            panic!("expected withdraw stop");
2572        };
2573        assert_eq!(items[0].qty, None);
2574        assert_eq!(items[1].qty, Some(5));
2575    }
2576
2577    #[test]
2578    fn legacy_harvest_loop_route_converts_to_ordered_stops() {
2579        let route = WorkerRouteView {
2580            kind: WorkerRouteKindView::HarvestLoop,
2581            lodging_container_id: Some("bed-1".into()),
2582            outbound_waypoints: vec![flatland_protocol::WorkerRouteWaypointView {
2583                x: 1.0,
2584                y: 2.0,
2585                z: 0.0,
2586            }],
2587            harvest_nodes: vec!["oak-1".into()],
2588            carry_return_ratio: 0.9,
2589            stops: Vec::new(),
2590        };
2591        let ed = WorkerRouteEditorState::from_saved_route("w1".into(), "L".into(), &route, None);
2592        // waypoint, harvest, deposit@bed, rest
2593        assert_eq!(ed.stops.len(), 4);
2594        assert!(
2595            matches!(&ed.stops[2], WorkerRouteStop::DepositAt { container_id, .. } if container_id == "bed-1")
2596        );
2597        assert!(matches!(&ed.stops[3], WorkerRouteStop::RestIfNeeded));
2598    }
2599
2600    // ---- sheet state machine ------------------------------------------
2601
2602    #[test]
2603    fn retarget_withdraw_container_updates_editing_stop() {
2604        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2605        ed.stops.push(WorkerRouteStop::WithdrawFrom {
2606            container_id: "chest-old".into(),
2607            items: vec![WorkerRouteWithdrawItem {
2608                template: "iron_ore".into(),
2609                qty: None,
2610            }],
2611        });
2612        ed.selected_stop_index = 0;
2613        ed.editing_index = Some(0);
2614        assert!(ed.retarget_withdraw_container("chest-new".into()));
2615        assert!(matches!(
2616            &ed.stops[0],
2617            WorkerRouteStop::WithdrawFrom { container_id, .. } if container_id == "chest-new"
2618        ));
2619    }
2620
2621    #[test]
2622    fn summary_resolved_uses_friendly_labels() {
2623        let stop = WorkerRouteStop::WithdrawFrom {
2624            container_id: "uuid-iron".into(),
2625            items: vec![WorkerRouteWithdrawItem {
2626                template: "iron_ore".into(),
2627                qty: None,
2628            }],
2629        };
2630        let summary = stop.summary_resolved(
2631            |_| "Iron Ore Container".into(),
2632            |_| "Ada".into(),
2633            |_| "Oak Tree".into(),
2634            |_| "Food Pad".into(),
2635        );
2636        assert_eq!(summary, "withdraw all iron_ore from Iron Ore Container");
2637        assert!(!summary.contains("uuid"));
2638    }
2639
2640    #[test]
2641    fn summary_resolved_uses_plot_labels() {
2642        let plot_id = uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap();
2643        let cultivate = WorkerRouteStop::CultivatePlot { plot_id };
2644        let plant = WorkerRouteStop::PlantPlot {
2645            plot_id,
2646            seed_template: "potato_seed".into(),
2647        };
2648        let harvest = WorkerRouteStop::HarvestPlot { plot_id };
2649        let friendly = |id: &uuid::Uuid| {
2650            assert_eq!(*id, plot_id);
2651            "Madsin — Starter Town East — Food Pad".to_string()
2652        };
2653        let blank = |_: &str| String::new();
2654        assert_eq!(
2655            cultivate.summary_resolved(blank, blank, blank, friendly),
2656            "cultivate Madsin — Starter Town East — Food Pad"
2657        );
2658        assert_eq!(
2659            plant.summary_resolved(blank, blank, blank, friendly),
2660            "plant potato_seed on Madsin — Starter Town East — Food Pad"
2661        );
2662        assert_eq!(
2663            harvest.summary_resolved(blank, blank, blank, friendly),
2664            "harvest Madsin — Starter Town East — Food Pad"
2665        );
2666        let named = cultivate.summary_resolved(blank, blank, blank, friendly);
2667        assert!(
2668            !named.contains("19fe35f"),
2669            "resolved plot summary must not include hex id: {named}"
2670        );
2671        let raw = cultivate.summary();
2672        assert_eq!(raw, "cultivate plot 19fe35f0");
2673    }
2674
2675    #[test]
2676    fn list_filter_row_matches_name_and_distance() {
2677        assert!(list_filter_row_matches(
2678            "oak",
2679            None,
2680            &["Oak Tree", "oak_log"]
2681        ));
2682        assert!(!list_filter_row_matches(
2683            "pine",
2684            None,
2685            &["Oak Tree", "oak_log"]
2686        ));
2687        assert!(list_filter_row_matches(
2688            "oak 50m",
2689            Some(40.0),
2690            &["Oak Tree"]
2691        ));
2692        assert!(!list_filter_row_matches(
2693            "oak 50m",
2694            Some(60.0),
2695            &["Oak Tree"]
2696        ));
2697        assert!(list_filter_row_matches("", Some(999.0), &["anything"]));
2698    }
2699
2700    #[test]
2701    fn node_candidates_sort_from_lodging_anchor_not_player() {
2702        use flatland_protocol::{ResourceNodeState, ResourceNodeView};
2703        fn node(id: &str, label: &str, x: f32) -> ResourceNodeView {
2704            ResourceNodeView {
2705                id: id.into(),
2706                label: label.into(),
2707                x,
2708                y: 0.0,
2709                z: 0.0,
2710                item_template: "oak_log".into(),
2711                state: ResourceNodeState::Available,
2712                blocking: true,
2713                blocking_radius_m: 0.8,
2714                harvest_off: false,
2715                tile_id: None,
2716                yaw: 0.0,
2717                pitch: 0.0,
2718                roll: 0.0,
2719                draw_scale: 1.0,
2720                sprite_mode: None,
2721                presentation_state: None,
2722                growth_progress: None,
2723                channel_start_tick: None,
2724                channel_end_tick: None,
2725                harvest_drop_templates: vec![],
2726            }
2727        }
2728        let nodes = vec![node("far", "Far Oak", 100.0), node("near", "Near Oak", 5.0)];
2729        let sorted = node_candidates(&nodes, 0.0, 0.0);
2730        assert_eq!(sorted[0].id, "near");
2731        assert_eq!(sorted[1].id, "far");
2732        assert!((sorted[0].dist - 5.0).abs() < 0.01);
2733
2734        let stable = node_candidates_stable(&nodes);
2735        assert!(
2736            stable[0].label.starts_with("Far Oak ("),
2737            "got {}",
2738            stable[0].label
2739        );
2740        assert!(
2741            stable[1].label.starts_with("Near Oak ("),
2742            "got {}",
2743            stable[1].label
2744        );
2745        assert!(stable[0].dist.is_nan());
2746    }
2747
2748    #[test]
2749    fn node_candidates_include_depleted_and_skip_preview() {
2750        use flatland_protocol::{ResourceNodeState, ResourceNodeView};
2751        fn node(id: &str, label: &str, state: ResourceNodeState) -> ResourceNodeView {
2752            ResourceNodeView {
2753                id: id.into(),
2754                label: label.into(),
2755                x: 0.0,
2756                y: 0.0,
2757                z: 0.0,
2758                item_template: "oak_log".into(),
2759                state,
2760                blocking: true,
2761                blocking_radius_m: 0.8,
2762                harvest_off: false,
2763                tile_id: None,
2764                yaw: 0.0,
2765                pitch: 0.0,
2766                roll: 0.0,
2767                draw_scale: 1.0,
2768                sprite_mode: None,
2769                presentation_state: None,
2770                growth_progress: None,
2771                channel_start_tick: None,
2772                channel_end_tick: None,
2773                harvest_drop_templates: vec![],
2774            }
2775        }
2776        let nodes = vec![
2777            node("oak-ready", "Ready Oak", ResourceNodeState::Available),
2778            node("oak-down", "Regen Oak", ResourceNodeState::Cooldown),
2779            node(
2780                "preview:neighbor-oak",
2781                "Ghost Oak",
2782                ResourceNodeState::Available,
2783            ),
2784            node(
2785                "carcass-wolf-1",
2786                "Wolf carcass",
2787                ResourceNodeState::Available,
2788            ),
2789        ];
2790        let ids: Vec<_> = node_candidates_stable(&nodes)
2791            .into_iter()
2792            .map(|n| n.id)
2793            .collect();
2794        assert!(ids.contains(&"oak-ready".to_string()));
2795        assert!(ids.contains(&"oak-down".to_string()));
2796        assert!(!ids.iter().any(|id| id.starts_with("preview:")));
2797        assert!(!ids.iter().any(|id| id.starts_with("carcass-")));
2798        assert_eq!(ids.len(), 2);
2799        let depleted = node_candidates_stable(&nodes)
2800            .into_iter()
2801            .find(|n| n.id == "oak-down")
2802            .unwrap();
2803        assert!(
2804            depleted.label.contains("depleted"),
2805            "got {}",
2806            depleted.label
2807        );
2808    }
2809
2810    #[test]
2811    fn sheet_back_walks_up_hierarchy() {
2812        use RouteEditorSheet as S;
2813        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2814        assert_eq!(ed.sheet, S::Stops);
2815        ed.open_add_menu();
2816        assert_eq!(ed.sheet, S::AddMenu { index: 0 });
2817        ed.open_sheet(S::WithdrawContainers { index: 0 });
2818        ed.open_sheet(S::WithdrawItems {
2819            container_id: "c1".into(),
2820            lines: vec![],
2821            index: 0,
2822        });
2823        ed.sheet_back();
2824        assert_eq!(ed.sheet, S::WithdrawContainers { index: 0 });
2825        ed.sheet_back();
2826        assert_eq!(ed.sheet, S::AddMenu { index: 0 });
2827        ed.sheet_back();
2828        assert_eq!(ed.sheet, S::Stops);
2829        // Root: back is a no-op.
2830        ed.sheet_back();
2831        assert_eq!(ed.sheet, S::Stops);
2832    }
2833
2834    #[test]
2835    fn sheet_back_while_editing_returns_to_stops() {
2836        use RouteEditorSheet as S;
2837        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2838        ed.append_harvest_node("oak-a");
2839        ed.begin_edit_selected();
2840        ed.open_sheet(S::HarvestPicker {
2841            index: 0,
2842            picked: BTreeSet::new(),
2843            nodes: Vec::new(),
2844        });
2845        ed.sheet_back();
2846        assert_eq!(ed.sheet, S::Stops);
2847        assert_eq!(ed.editing_index, None);
2848    }
2849
2850    #[test]
2851    fn sheet_back_while_editing_withdraw_keeps_edit_on_container_picker() {
2852        use RouteEditorSheet as S;
2853        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2854        ed.stops.push(WorkerRouteStop::WithdrawFrom {
2855            container_id: "chest-a".into(),
2856            items: vec![WorkerRouteWithdrawItem {
2857                template: "oak_log".into(),
2858                qty: None,
2859            }],
2860        });
2861        ed.begin_edit_selected();
2862        ed.open_sheet(S::WithdrawItems {
2863            container_id: "chest-a".into(),
2864            lines: vec![],
2865            index: 0,
2866        });
2867        ed.sheet_back();
2868        assert!(matches!(ed.sheet, S::WithdrawContainers { .. }));
2869        assert_eq!(
2870            ed.editing_index,
2871            Some(0),
2872            "still editing after back to picker"
2873        );
2874        ed.sheet_back();
2875        assert_eq!(ed.sheet, S::Stops);
2876        assert_eq!(ed.editing_index, None);
2877    }
2878
2879    #[test]
2880    fn confirm_stop_replaces_withdraw_container_when_editing() {
2881        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2882        ed.stops.push(WorkerRouteStop::WithdrawFrom {
2883            container_id: "chest-old".into(),
2884            items: vec![WorkerRouteWithdrawItem {
2885                template: "oak_log".into(),
2886                qty: None,
2887            }],
2888        });
2889        ed.stops.push(WorkerRouteStop::RestIfNeeded);
2890        ed.select_stop(0);
2891        ed.begin_edit_selected();
2892        assert!(ed.confirm_stop(WorkerRouteStop::WithdrawFrom {
2893            container_id: "chest-new".into(),
2894            items: vec![WorkerRouteWithdrawItem {
2895                template: "oak_log".into(),
2896                qty: None,
2897            }],
2898        }));
2899        assert_eq!(ed.stops.len(), 2);
2900        assert!(matches!(
2901            &ed.stops[0],
2902            WorkerRouteStop::WithdrawFrom { container_id, .. } if container_id == "chest-new"
2903        ));
2904    }
2905
2906    #[test]
2907    fn confirm_stop_replaces_when_editing() {
2908        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2909        ed.append_harvest_node("oak-a");
2910        ed.append_waypoint(1.0, 1.0, 0.0);
2911        ed.select_stop(0);
2912        ed.begin_edit_selected();
2913        assert!(ed.confirm_stop(WorkerRouteStop::HarvestNode {
2914            node_id: "oak-b".into()
2915        }));
2916        assert_eq!(ed.stops.len(), 2, "edit replaces in place, no append");
2917        assert!(
2918            matches!(&ed.stops[0], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
2919        );
2920        assert_eq!(ed.sheet, RouteEditorSheet::Stops);
2921        assert_eq!(ed.editing_index, None);
2922    }
2923
2924    #[test]
2925    fn confirm_stop_dedupes_on_append() {
2926        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
2927        ed.append_harvest_node("oak-a");
2928        assert!(!ed.confirm_stop(WorkerRouteStop::HarvestNode {
2929            node_id: "oak-a".into()
2930        }));
2931        assert_eq!(ed.stops.len(), 1);
2932        assert_eq!(ed.selected_stop_index, 0);
2933    }
2934
2935    #[test]
2936    fn withdraw_line_cycle_and_collect() {
2937        let contents = vec![
2938            ItemStack {
2939                template_id: "oak_log".into(),
2940                quantity: 12,
2941                ..Default::default()
2942            },
2943            ItemStack {
2944                template_id: "lumber".into(),
2945                quantity: 4,
2946                ..Default::default()
2947            },
2948        ];
2949        let mut lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &[]);
2950        assert_eq!(lines.len(), 2);
2951        // cycle oak_log (sorted first: lumber, oak_log)
2952        lines[1].cycle();
2953        assert_eq!(lines[1].mode, WithdrawLineMode::All);
2954        lines[0].cycle();
2955        lines[0].cycle();
2956        assert!(matches!(lines[0].mode, WithdrawLineMode::Qty(_)));
2957        lines[0].adjust_qty(5);
2958        let items = WorkerRouteEditorState::withdraw_items_from_lines(&lines);
2959        assert_eq!(items.len(), 2);
2960        assert_eq!(items[0].template, "lumber");
2961        // lumber available = 4: Qty starts at 4, +5 clamps back to 4.
2962        assert_eq!(items[0].qty, Some(4));
2963        assert_eq!(items[1].qty, None);
2964    }
2965
2966    #[test]
2967    fn withdraw_drafts_prefill_existing_and_keep_missing() {
2968        let contents = vec![ItemStack {
2969            template_id: "oak_log".into(),
2970            quantity: 3,
2971            ..Default::default()
2972        }];
2973        let existing = vec![
2974            WorkerRouteWithdrawItem {
2975                template: "oak_log".into(),
2976                qty: None,
2977            },
2978            WorkerRouteWithdrawItem {
2979                template: "iron_ore".into(),
2980                qty: Some(5),
2981            },
2982        ];
2983        let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
2984        assert_eq!(lines.len(), 2);
2985        let ore = lines
2986            .iter()
2987            .find(|l| l.template == "iron_ore")
2988            .expect("ore line");
2989        assert_eq!(ore.available, 0, "missing template kept with 0 available");
2990        assert_eq!(ore.mode, WithdrawLineMode::Qty(5));
2991        let oak = lines
2992            .iter()
2993            .find(|l| l.template == "oak_log")
2994            .expect("oak line");
2995        assert_eq!(oak.mode, WithdrawLineMode::All);
2996    }
2997}