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