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 flatland_protocol::{
16    ItemStack, NpcView, PlacedContainerView, ResourceNodeView, WorkerRouteKindView,
17    WorkerRouteStopView, WorkerRouteView,
18};
19
20/// One outbound travel waypoint for a harvest loop route.
21#[derive(Debug, Clone, PartialEq)]
22pub struct WorkerRouteWaypoint {
23    pub x: f32,
24    pub y: f32,
25    pub z: f32,
26}
27
28/// One typed stop in an ordered worker route. Mirrors the sim `WorkerRouteStop`.
29#[derive(Debug, Clone, PartialEq)]
30pub enum WorkerRouteStop {
31    Waypoint { x: f32, y: f32, z: f32 },
32    HarvestNode { node_id: String },
33    DepositAt {
34        container_id: String,
35        /// When set, deposit only these templates (lets a worker keep tools across loops).
36        filter: Option<Vec<String>>,
37    },
38    /// Travel to an NPC and sell `template` (whole stack when `sell_all`).
39    /// `npc_id: None` → auto-pick the nearest NPC that buys `template`.
40    TradeWith {
41        npc_id: Option<String>,
42        template: String,
43        sell_all: bool,
44    },
45    /// Withdraw specific items from an owned storage container.
46    WithdrawFrom {
47        container_id: String,
48        items: Vec<WorkerRouteWithdrawItem>,
49    },
50    /// Craft `blueprint` at `device` (or `"hand"`). `qty: None` = until inputs exhausted.
51    CraftAt {
52        device: String,
53        blueprint: String,
54        qty: Option<u32>,
55    },
56    RestIfNeeded,
57    Wait { wait_ticks: u64 },
58}
59
60/// One withdraw line for a `WithdrawFrom` editor stop.
61#[derive(Debug, Clone, PartialEq)]
62pub struct WorkerRouteWithdrawItem {
63    pub template: String,
64    /// `None` = take every stack of this template (carry-capped; the worker
65    /// loops back for the rest). `Some(n)` = take exactly n.
66    pub qty: Option<u32>,
67}
68
69impl WorkerRouteStop {
70    pub fn summary(&self) -> String {
71        self.summary_resolved(|id| short_id(id), |id| id.to_string(), |id| id.to_string())
72    }
73
74    /// Human-facing summary using friendly labels for containers / NPCs / nodes.
75    pub fn summary_resolved(
76        &self,
77        container_label: impl Fn(&str) -> String,
78        npc_label: impl Fn(&str) -> String,
79        node_label: impl Fn(&str) -> String,
80    ) -> String {
81        match self {
82            Self::Waypoint { x, y, .. } => format!("waypoint ({x:.0}, {y:.0})"),
83            Self::HarvestNode { node_id } => format!("harvest {}", node_label(node_id)),
84            Self::DepositAt { container_id, filter } => {
85                let f = filter
86                    .as_ref()
87                    .map(|f| format!(" only {}", f.join(",")))
88                    .unwrap_or_default();
89                format!("deposit at {}{f}", container_label(container_id))
90            }
91            Self::TradeWith { npc_id, template, .. } => {
92                let who = npc_id
93                    .as_deref()
94                    .map(|id| npc_label(id))
95                    .unwrap_or_else(|| "nearest buyer".into());
96                format!("sell {template} to {who}")
97            }
98            Self::WithdrawFrom { container_id, items } => {
99                let what = items
100                    .iter()
101                    .map(|i| match i.qty {
102                        None => format!("all {}", i.template),
103                        Some(q) => format!("{q} {}", i.template),
104                    })
105                    .collect::<Vec<_>>()
106                    .join(" + ");
107                format!("withdraw {what} from {}", container_label(container_id))
108            }
109            Self::CraftAt { blueprint, .. } => format!("craft {blueprint}"),
110            Self::RestIfNeeded => "rest if needed".into(),
111            Self::Wait { wait_ticks } => format!("wait {wait_ticks}t"),
112        }
113    }
114
115    /// Short label for the editor list (without the stop index).
116    pub fn kind_label(&self) -> &'static str {
117        match self {
118            Self::Waypoint { .. } => "waypoint",
119            Self::HarvestNode { .. } => "harvest",
120            Self::DepositAt { .. } => "deposit",
121            Self::TradeWith { .. } => "sell",
122            Self::WithdrawFrom { .. } => "withdraw",
123            Self::CraftAt { .. } => "craft",
124            Self::RestIfNeeded => "rest",
125            Self::Wait { .. } => "wait",
126        }
127    }
128}
129
130fn short_id(id: &str) -> String {
131    id.rsplit('-')
132        .next()
133        .filter(|s| !s.is_empty())
134        .unwrap_or(id)
135        .to_string()
136}
137
138// ---- sheets (per-stop setup screens, `plans/33`) -------------------
139
140/// Tri-state of one withdraw line draft: not included, take all, take qty.
141#[derive(Debug, Clone, Copy, PartialEq)]
142pub enum WithdrawLineMode {
143    Off,
144    All,
145    Qty(u32),
146}
147
148/// One editable withdraw line inside the `WithdrawItems` sheet.
149#[derive(Debug, Clone, PartialEq)]
150pub struct WithdrawLineDraft {
151    pub template: String,
152    /// Total quantity currently in the chosen container (0 = no longer there).
153    pub available: u32,
154    pub mode: WithdrawLineMode,
155}
156
157impl WithdrawLineDraft {
158    /// Cycle Off → All → Qty → Off (Enter/Space on the row).
159    pub fn cycle(&mut self) {
160        self.mode = match self.mode {
161            WithdrawLineMode::Off => WithdrawLineMode::All,
162            WithdrawLineMode::All => WithdrawLineMode::Qty(self.available.clamp(1, 10)),
163            WithdrawLineMode::Qty(_) => WithdrawLineMode::Off,
164        };
165    }
166
167    /// Nudge the quantity; switches the line into `Qty` mode when needed.
168    pub fn adjust_qty(&mut self, delta: i32) {
169        let cur = match self.mode {
170            WithdrawLineMode::Off => self.available.clamp(1, 10),
171            WithdrawLineMode::All => self.available.clamp(1, 10),
172            WithdrawLineMode::Qty(q) => q,
173        };
174        let next = (cur as i32 + delta).clamp(1, self.available.max(1) as i32) as u32;
175        self.mode = WithdrawLineMode::Qty(next);
176    }
177}
178
179/// Which sheet (sub-screen) the route editor is showing.
180#[derive(Debug, Clone, PartialEq)]
181pub enum RouteEditorSheet {
182    /// Root: the ordered stop list.
183    Stops,
184    /// "Add stop" menu — choose a stop type.
185    AddMenu { index: usize },
186    /// Waypoint submenu (player position / map click).
187    WaypointMenu { index: usize },
188    /// "Click the map to place the waypoint" transient mode.
189    WaypointMapPick,
190    /// Pick a harvest node from the region list.
191    HarvestPicker { index: usize },
192    /// Withdraw step 1: pick the source container.
193    WithdrawContainers { index: usize },
194    /// Withdraw step 2: choose items + All/qty within the container.
195    WithdrawItems {
196        container_id: String,
197        lines: Vec<WithdrawLineDraft>,
198        index: usize,
199    },
200    /// Deposit step 1: pick the target container.
201    DepositContainers { index: usize },
202    /// Deposit step 2: optional "only these templates" filter.
203    DepositFilter {
204        container_id: String,
205        /// (template, chosen) rows; empty selection = deposit everything.
206        rows: Vec<(String, bool)>,
207        index: usize,
208    },
209    /// Sell step 1: pick the merchant (or auto).
210    SellNpcs { index: usize },
211    /// Sell step 2: pick the item template (+ sell-all toggle).
212    SellItem {
213        npc_id: Option<String>,
214        templates: Vec<String>,
215        index: usize,
216        sell_all: bool,
217    },
218    /// Craft: pick a blueprint (at `hand`).
219    CraftBlueprint { index: usize },
220    /// Wait stop: adjust ticks.
221    WaitEntry { ticks: u64 },
222    /// Pick the rest bed (lodging).
223    BedPicker { index: usize },
224}
225
226/// Mouse actions produced by the gfx overlay and applied on the net thread.
227#[derive(Debug, Clone, Copy, PartialEq)]
228pub enum RouteEditorClick {
229    /// Click a stop row: select it and focus the stop list.
230    SelectStop(usize),
231    /// Click the rest-bed header row: open the bed picker.
232    OpenBedPicker,
233    /// Click a sheet row: move the sheet cursor there and activate (Enter).
234    SheetRow(usize),
235    /// Toggle between the full panel and the minimized map-friendly bar.
236    TogglePanel,
237}
238
239/// Add-menu entries in display order.
240pub const ADD_MENU: &[&str] = &[
241    "Waypoint",
242    "Harvest node",
243    "Withdraw from storage",
244    "Deposit to storage",
245    "Sell to merchant",
246    "Craft (at hand)",
247    "Rest if needed",
248    "Wait",
249];
250
251/// Waypoint submenu entries in display order.
252pub const WAYPOINT_MENU: &[&str] = &["At player position", "Pick on map (click)"];
253
254// ---- picker candidate snapshots ------------------------------------
255
256/// One owned container row for the withdraw/deposit container pickers.
257#[derive(Debug, Clone, PartialEq)]
258pub struct ContainerCandidate {
259    pub id: String,
260    pub name: String,
261    pub is_lodging: bool,
262    /// e.g. `oak_log ×12 · lumber ×4` or `(empty)`.
263    pub summary: String,
264    pub dist: f32,
265}
266
267/// Summarize container contents as `oak_log ×12 · lumber ×4` (or `(empty)`).
268pub fn summarize_contents(contents: &[ItemStack]) -> String {
269    let mut totals: Vec<(String, u32)> = Vec::new();
270    for s in contents {
271        if s.template_id.is_empty() {
272            continue;
273        }
274        match totals.iter_mut().find(|(t, _)| t == &s.template_id) {
275            Some((_, q)) => *q += s.quantity,
276            None => totals.push((s.template_id.clone(), s.quantity)),
277        }
278    }
279    if totals.is_empty() {
280        return "(empty)".into();
281    }
282    totals.sort();
283    totals
284        .iter()
285        .map(|(t, q)| format!("{t} ×{q}"))
286        .collect::<Vec<_>>()
287        .join(" · ")
288}
289
290/// Owned containers (any with storage capacity, lodging flagged) sorted by
291/// distance to the player then name — the withdraw/deposit picker rows.
292pub fn owned_container_candidates(
293    placed: &[PlacedContainerView],
294    character_id: Option<uuid::Uuid>,
295    px: f32,
296    py: f32,
297) -> Vec<ContainerCandidate> {
298    owned_container_candidates_with_occupants(placed, character_id, px, py, &[])
299}
300
301/// Like [`owned_container_candidates`], but lodging rows include occupant names in `summary`.
302pub fn owned_container_candidates_with_occupants(
303    placed: &[PlacedContainerView],
304    character_id: Option<uuid::Uuid>,
305    px: f32,
306    py: f32,
307    hired: &[flatland_protocol::HiredWorkerView],
308) -> Vec<ContainerCandidate> {
309    let Some(cid) = character_id else {
310        return Vec::new();
311    };
312    let mut out: Vec<ContainerCandidate> = placed
313        .iter()
314        .filter(|c| c.owner_character_id == Some(cid))
315        .filter(|c| {
316            c.capacity_volume.unwrap_or(0.0) > 0.0 || c.worker_lodging_capacity.unwrap_or(0) > 0
317        })
318        .map(|c| {
319            let is_lodging = c.worker_lodging_capacity.unwrap_or(0) > 0;
320            let mut summary = summarize_contents(&c.contents);
321            if is_lodging {
322                let who = lodging_occupants_for(hired, &c.id);
323                let who = if who.is_empty() {
324                    "vacant".into()
325                } else {
326                    who.join(", ")
327                };
328                summary = format!("lodged: {who} · {summary}");
329            }
330            ContainerCandidate {
331                id: c.id.clone(),
332                name: c.display_name.clone(),
333                is_lodging,
334                summary,
335                dist: dist2d(px, py, c.x, c.y),
336            }
337        })
338        .collect();
339    out.sort_by(|a, b| {
340        a.dist
341            .partial_cmp(&b.dist)
342            .unwrap_or(std::cmp::Ordering::Equal)
343            .then_with(|| a.name.cmp(&b.name))
344            .then_with(|| a.id.cmp(&b.id))
345    });
346    out
347}
348
349fn lodging_occupants_for(
350    hired: &[flatland_protocol::HiredWorkerView],
351    container_id: &str,
352) -> Vec<String> {
353    let mut names: Vec<String> = hired
354        .iter()
355        .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
356        .map(|w| w.label.clone())
357        .collect();
358    names.sort();
359    names
360}
361
362/// One resource-node row for the harvest picker.
363#[derive(Debug, Clone, PartialEq)]
364pub struct NodeCandidate {
365    pub id: String,
366    pub label: String,
367    pub template: String,
368    pub dist: f32,
369}
370
371/// Harvestable resource nodes sorted by distance to the player.
372pub fn node_candidates(nodes: &[ResourceNodeView], px: f32, py: f32) -> Vec<NodeCandidate> {
373    let mut out: Vec<NodeCandidate> = nodes
374        .iter()
375        .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
376        .map(|n| NodeCandidate {
377            id: n.id.clone(),
378            label: n.label.clone(),
379            template: n.item_template.clone(),
380            dist: dist2d(px, py, n.x, n.y),
381        })
382        .collect();
383    out.sort_by(|a, b| {
384        a.dist
385            .partial_cmp(&b.dist)
386            .unwrap_or(std::cmp::Ordering::Equal)
387            .then_with(|| a.id.cmp(&b.id))
388    });
389    out
390}
391
392/// One merchant row for the sell picker.
393#[derive(Debug, Clone, PartialEq)]
394pub struct TradeNpcCandidate {
395    pub id: String,
396    pub label: String,
397    pub dist: f32,
398}
399
400/// Trade-capable NPCs sorted by distance to the player.
401pub fn trade_npc_candidates(npcs: &[NpcView], px: f32, py: f32) -> Vec<TradeNpcCandidate> {
402    let mut out: Vec<TradeNpcCandidate> = npcs
403        .iter()
404        .filter(|n| n.can_trade)
405        .map(|n| TradeNpcCandidate {
406            id: n.id.clone(),
407            label: n.label.clone(),
408            dist: dist2d(px, py, n.x, n.y),
409        })
410        .collect();
411    out.sort_by(|a, b| {
412        a.dist
413            .partial_cmp(&b.dist)
414            .unwrap_or(std::cmp::Ordering::Equal)
415            .then_with(|| a.id.cmp(&b.id))
416    });
417    out
418}
419
420// ---- editor state ---------------------------------------------------
421
422/// Draft route for a hired worker. `stops` is the ordered, editable list;
423/// `lodging_container_id` is the bed used for `RestIfNeeded`.
424#[derive(Debug, Clone)]
425pub struct WorkerRouteEditorState {
426    pub worker_instance_id: String,
427    pub worker_label: String,
428    pub lodging_container_id: Option<String>,
429    /// Ordered, player-authored stops. May be empty until the player adds some.
430    pub stops: Vec<WorkerRouteStop>,
431    /// Selected stop index for reorder / delete operations.
432    pub selected_stop_index: usize,
433    /// Carry threshold passed to each `HarvestNode` stop's compiled `HarvestRoute`.
434    pub carry_return_ratio: f32,
435    /// Active sheet (sub-screen). `Stops` is the root.
436    pub sheet: RouteEditorSheet,
437    /// When set, confirming a sheet **replaces** this stop instead of appending
438    /// (Enter on a stop = edit it in place).
439    pub editing_index: Option<usize>,
440    /// When true, only a thin bar is drawn so the map stays visible for clicks.
441    pub panel_collapsed: bool,
442}
443
444impl WorkerRouteEditorState {
445    pub fn new(
446        worker_instance_id: String,
447        worker_label: String,
448        lodging_container_id: Option<String>,
449    ) -> Self {
450        Self {
451            worker_instance_id,
452            worker_label,
453            lodging_container_id,
454            stops: Vec::new(),
455            selected_stop_index: 0,
456            carry_return_ratio: 0.90,
457            sheet: RouteEditorSheet::Stops,
458            editing_index: None,
459            panel_collapsed: false,
460        }
461    }
462
463    pub fn toggle_panel_collapsed(&mut self) {
464        self.panel_collapsed = !self.panel_collapsed;
465    }
466
467    pub fn from_saved_route(
468        worker_instance_id: String,
469        worker_label: String,
470        route: &WorkerRouteView,
471        lodging_fallback: Option<String>,
472    ) -> Self {
473        let lodging = route
474            .lodging_container_id
475            .clone()
476            .or(lodging_fallback);
477
478        match route.kind {
479            WorkerRouteKindView::Ordered => Self {
480                worker_instance_id,
481                worker_label,
482                lodging_container_id: lodging,
483                stops: route
484                    .stops
485                    .iter()
486                    .map(stop_view_to_stop)
487                    .collect(),
488                selected_stop_index: 0,
489                carry_return_ratio: route.carry_return_ratio,
490                sheet: RouteEditorSheet::Stops,
491                editing_index: None,
492                panel_collapsed: false,
493            },
494            WorkerRouteKindView::HarvestLoop => {
495                // Convert legacy harvest_loop shape into an ordered stop list so
496                // the editor presents one unified model. Outbound waypoints and
497                // harvest nodes are interleaved in declaration order (waypoints
498                // first, then harvest, then deposit at lodging, then rest).
499                let mut stops = Vec::new();
500                for wp in &route.outbound_waypoints {
501                    stops.push(WorkerRouteStop::Waypoint {
502                        x: wp.x,
503                        y: wp.y,
504                        z: wp.z,
505                    });
506                }
507                for node in &route.harvest_nodes {
508                    stops.push(WorkerRouteStop::HarvestNode {
509                        node_id: node.clone(),
510                    });
511                }
512                if let Some(lodging) = &lodging {
513                    stops.push(WorkerRouteStop::DepositAt {
514                        container_id: lodging.clone(),
515                        filter: None,
516                    });
517                    stops.push(WorkerRouteStop::RestIfNeeded);
518                }
519                Self {
520                    worker_instance_id,
521                    worker_label,
522                    lodging_container_id: lodging,
523                    stops,
524                    selected_stop_index: 0,
525                    carry_return_ratio: route.carry_return_ratio,
526                    sheet: RouteEditorSheet::Stops,
527                    editing_index: None,
528                    panel_collapsed: false,
529                }
530            }
531        }
532    }
533
534    // ---- stop list editing ------------------------------------------
535
536    pub fn stop_count(&self) -> usize {
537        self.stops.len()
538    }
539
540    pub fn select_stop(&mut self, index: usize) {
541        if self.stops.is_empty() {
542            self.selected_stop_index = 0;
543            return;
544        }
545        self.selected_stop_index = index.min(self.stops.len() - 1);
546    }
547
548    pub fn move_selected_up(&mut self) {
549        if self.selected_stop_index == 0 {
550            return;
551        }
552        self.stops
553            .swap(self.selected_stop_index, self.selected_stop_index - 1);
554        self.selected_stop_index -= 1;
555    }
556
557    pub fn move_selected_down(&mut self) {
558        if self.selected_stop_index + 1 >= self.stops.len() {
559            return;
560        }
561        self.stops
562            .swap(self.selected_stop_index, self.selected_stop_index + 1);
563        self.selected_stop_index += 1;
564    }
565
566    pub fn remove_selected_stop(&mut self) {
567        if self.stops.is_empty() {
568            return;
569        }
570        let idx = self.selected_stop_index.min(self.stops.len() - 1);
571        self.stops.remove(idx);
572        self.editing_index = None;
573        if self.selected_stop_index >= self.stops.len() {
574            self.selected_stop_index = self.stops.len().saturating_sub(1);
575        }
576    }
577
578    /// Index of the first stop matching `pred`, if any.
579    fn find_stop(&self, pred: impl Fn(&WorkerRouteStop) -> bool) -> Option<usize> {
580        self.stops.iter().position(pred)
581    }
582
583    pub fn harvest_node_index(&self, node_id: &str) -> Option<usize> {
584        self.find_stop(
585            |s| matches!(s, WorkerRouteStop::HarvestNode { node_id: n } if n == node_id),
586        )
587    }
588
589    pub fn deposit_container_index(&self, container_id: &str) -> Option<usize> {
590        self.find_stop(
591            |s| matches!(s, WorkerRouteStop::DepositAt { container_id: c, .. } if c == container_id),
592        )
593    }
594
595    pub fn trade_stop_index(&self, npc_id: Option<&str>, template: &str) -> Option<usize> {
596        self.find_stop(|s| {
597            matches!(s, WorkerRouteStop::TradeWith { npc_id: n, template: t, .. }
598                if n.as_deref() == npc_id && t == template)
599        })
600    }
601
602    /// Insert a stop, deduping targets that must not repeat (harvest nodes,
603    /// deposit containers, identical sell stops): a duplicate selects the
604    /// existing stop instead of appending. Returns (appended, index).
605    pub fn insert_stop(&mut self, stop: WorkerRouteStop) -> (bool, usize) {
606        let existing = match &stop {
607            WorkerRouteStop::HarvestNode { node_id } => self.harvest_node_index(node_id),
608            WorkerRouteStop::DepositAt { container_id, .. } => {
609                self.deposit_container_index(container_id)
610            }
611            WorkerRouteStop::TradeWith { npc_id, template, .. } => {
612                self.trade_stop_index(npc_id.as_deref(), template)
613            }
614            _ => None,
615        };
616        if let Some(idx) = existing {
617            self.selected_stop_index = idx;
618            return (false, idx);
619        }
620        self.stops.push(stop);
621        self.selected_stop_index = self.stops.len() - 1;
622        (true, self.stops.len() - 1)
623    }
624
625    pub fn append_waypoint(&mut self, x: f32, y: f32, z: f32) {
626        self.insert_stop(WorkerRouteStop::Waypoint { x, y, z });
627    }
628
629    pub fn append_harvest_node(&mut self, node_id: &str) -> bool {
630        self.insert_stop(WorkerRouteStop::HarvestNode {
631            node_id: node_id.to_string(),
632        })
633        .0
634    }
635
636    pub fn append_deposit_at(&mut self, container_id: &str) -> bool {
637        self.insert_stop(WorkerRouteStop::DepositAt {
638            container_id: container_id.to_string(),
639            filter: None,
640        })
641        .0
642    }
643
644    /// Append a filtered deposit (deposit only `filter_templates`, keep everything else —
645    /// e.g. keep the worker's handsaw while depositing lumber).
646    pub fn append_deposit_at_filtered(&mut self, container_id: &str, filter_templates: Vec<String>) {
647        self.stops.push(WorkerRouteStop::DepositAt {
648            container_id: container_id.to_string(),
649            filter: Some(filter_templates),
650        });
651        self.selected_stop_index = self.stops.len() - 1;
652    }
653
654    pub fn append_rest_if_needed(&mut self) {
655        self.insert_stop(WorkerRouteStop::RestIfNeeded);
656    }
657
658    pub fn append_wait(&mut self, wait_ticks: u64) {
659        self.insert_stop(WorkerRouteStop::Wait { wait_ticks });
660    }
661
662    /// Append a `TradeWith` stop — sell `template` (whole stack) to `npc_id`
663    /// (`None` = auto-pick the nearest NPC that buys it). An identical sell
664    /// stop (same merchant + template) is selected instead of duplicated.
665    pub fn append_trade_with(&mut self, template: String, npc_id: Option<String>, sell_all: bool) -> bool {
666        self.insert_stop(WorkerRouteStop::TradeWith {
667            npc_id,
668            template,
669            sell_all,
670        })
671        .0
672    }
673
674    /// Pin the selected stop's NPC when the player clicks a merchant on the map.
675    /// Only affects `TradeWith` stops; returns true if a stop was updated.
676    /// If pinning would duplicate another sell stop (same merchant + template),
677    /// the selected stop is removed and the existing one is selected instead.
678    pub fn set_selected_trade_npc(&mut self, npc_id: String) -> bool {
679        let Some(stop) = self.stops.get_mut(self.selected_stop_index) else {
680            return false;
681        };
682        let WorkerRouteStop::TradeWith { npc_id: slot, template, .. } = stop else {
683            return false;
684        };
685        *slot = Some(npc_id.clone());
686        let template = template.clone();
687        let selected = self.selected_stop_index;
688        if let Some(other) = self
689            .trade_stop_index(Some(npc_id.as_str()), template.as_str())
690            .filter(|&i| i != selected)
691        {
692            self.stops.remove(selected);
693            self.selected_stop_index = if other > selected { other - 1 } else { other };
694        }
695        true
696    }
697
698    /// Retarget the selected stop's source container when the player clicks a
699    /// chest on the map. Only affects `WithdrawFrom` stops; returns true if a
700    /// stop was updated.
701    pub fn set_selected_withdraw_container(&mut self, container_id: String) -> bool {
702        let Some(stop) = self.stops.get_mut(self.selected_stop_index) else {
703            return false;
704        };
705        if let WorkerRouteStop::WithdrawFrom { container_id: slot, .. } = stop {
706            *slot = container_id;
707            return true;
708        }
709        false
710    }
711
712    /// Retarget the stop currently being edited (or selected) to a new withdraw
713    /// chest. Prefer `editing_index` when set.
714    pub fn retarget_withdraw_container(&mut self, container_id: String) -> bool {
715        let idx = self.editing_index.unwrap_or(self.selected_stop_index);
716        let Some(stop) = self.stops.get_mut(idx) else {
717            return false;
718        };
719        if let WorkerRouteStop::WithdrawFrom { container_id: slot, .. } = stop {
720            *slot = container_id;
721            return true;
722        }
723        false
724    }
725
726    /// Retarget the stop currently being edited (or selected) to a new deposit
727    /// chest. Prefer `editing_index` when set.
728    pub fn retarget_deposit_container(&mut self, container_id: String) -> bool {
729        let idx = self.editing_index.unwrap_or(self.selected_stop_index);
730        let Some(stop) = self.stops.get_mut(idx) else {
731            return false;
732        };
733        if let WorkerRouteStop::DepositAt { container_id: slot, .. } = stop {
734            *slot = container_id;
735            return true;
736        }
737        false
738    }
739
740    // ---- sheet navigation --------------------------------------------
741
742    pub fn open_add_menu(&mut self) {
743        self.editing_index = None;
744        self.sheet = RouteEditorSheet::AddMenu { index: 0 };
745    }
746
747    pub fn open_sheet(&mut self, sheet: RouteEditorSheet) {
748        self.sheet = sheet;
749    }
750
751    /// Mark the selected stop as being edited; the next `confirm_stop` replaces
752    /// it in place. Caller then opens the matching sheet (prefilled).
753    pub fn begin_edit_selected(&mut self) {
754        if self.selected_stop_index < self.stops.len() {
755            self.editing_index = Some(self.selected_stop_index);
756        }
757    }
758
759    /// Esc pops one sheet level. When editing, Esc from an items/filter sheet
760    /// returns to the container picker (still editing); Esc from the picker
761    /// cancels the edit. Root (`Stops`) is a no-op — the caller closes the editor.
762    pub fn sheet_back(&mut self) {
763        use RouteEditorSheet as S;
764        let editing = self.editing_index.is_some();
765        let next = match &self.sheet {
766            S::Stops => return,
767            S::AddMenu { .. } | S::BedPicker { .. } => S::Stops,
768            S::WaypointMapPick => {
769                if editing {
770                    S::Stops
771                } else {
772                    S::WaypointMenu { index: 0 }
773                }
774            }
775            // While editing, back out of items/filter to the chest/NPC picker
776            // so the player can retarget without canceling the whole edit.
777            S::WithdrawItems { .. } => S::WithdrawContainers { index: 0 },
778            S::DepositFilter { .. } => S::DepositContainers { index: 0 },
779            S::SellItem { .. } => S::SellNpcs { index: 0 },
780            S::WithdrawContainers { .. }
781            | S::DepositContainers { .. }
782            | S::SellNpcs { .. }
783                if editing =>
784            {
785                S::Stops
786            }
787            // Top-level sheets: back to the Add menu (or Stops when editing).
788            _ => {
789                if editing {
790                    S::Stops
791                } else {
792                    S::AddMenu { index: 0 }
793                }
794            }
795        };
796        if matches!(next, S::Stops) {
797            self.editing_index = None;
798        }
799        self.sheet = next;
800    }
801
802    /// Confirm the current sheet's stop: replace the edited stop in place, or
803    /// append (deduped). Returns true when a stop was appended/replaced and
804    /// false when a duplicate selected the existing stop instead.
805    pub fn confirm_stop(&mut self, stop: WorkerRouteStop) -> bool {
806        let result = if let Some(idx) = self.editing_index.take() {
807            if idx < self.stops.len() {
808                self.stops[idx] = stop;
809                self.selected_stop_index = idx;
810            }
811            true
812        } else {
813            self.insert_stop(stop).0
814        };
815        self.sheet = RouteEditorSheet::Stops;
816        result
817    }
818
819    /// Build withdraw line drafts for `container_id` from its contents.
820    /// `existing` pre-fills modes (editing an existing stop); templates no
821    /// longer present are kept with `available: 0`.
822    pub fn withdraw_line_drafts(
823        contents: &[ItemStack],
824        existing: &[WorkerRouteWithdrawItem],
825    ) -> Vec<WithdrawLineDraft> {
826        let mut lines: Vec<WithdrawLineDraft> = Vec::new();
827        for s in contents {
828            if s.template_id.is_empty() {
829                continue;
830            }
831            match lines.iter_mut().find(|l| l.template == s.template_id) {
832                Some(l) => l.available += s.quantity,
833                None => lines.push(WithdrawLineDraft {
834                    template: s.template_id.clone(),
835                    available: s.quantity,
836                    mode: WithdrawLineMode::Off,
837                }),
838            }
839        }
840        for item in existing {
841            let mode = match item.qty {
842                None => WithdrawLineMode::All,
843                Some(q) => WithdrawLineMode::Qty(q),
844            };
845            match lines.iter_mut().find(|l| l.template == item.template) {
846                Some(l) => l.mode = mode,
847                None => lines.push(WithdrawLineDraft {
848                    template: item.template.clone(),
849                    available: 0,
850                    mode,
851                }),
852            }
853        }
854        lines.sort_by(|a, b| a.template.cmp(&b.template));
855        lines
856    }
857
858    /// Collect the active withdraw lines into stop items (`None` = all).
859    pub fn withdraw_items_from_lines(lines: &[WithdrawLineDraft]) -> Vec<WorkerRouteWithdrawItem> {
860        lines
861            .iter()
862            .filter_map(|l| match l.mode {
863                WithdrawLineMode::Off => None,
864                WithdrawLineMode::All => Some(WorkerRouteWithdrawItem {
865                    template: l.template.clone(),
866                    qty: None,
867                }),
868                WithdrawLineMode::Qty(q) => Some(WorkerRouteWithdrawItem {
869                    template: l.template.clone(),
870                    qty: Some(q),
871                }),
872            })
873            .collect()
874    }
875
876    // ---- YAML emission -----------------------------------------------
877
878    fn job_id(&self) -> String {
879        format!(
880            "route_{}",
881            self.worker_instance_id
882                .chars()
883                .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
884                .collect::<String>()
885        )
886    }
887
888    /// YAML that parks the worker: `mode: idle` with no steps. Saving an empty
889    /// route means "stand down" rather than leaving the worker in a broken
890    /// job loop.
891    pub fn build_idle_job_yaml(&self) -> String {
892        // No `route` block — an empty ordered route is rejected server-side,
893        // and `mode: idle` with no steps is the explicit "stand down" signal.
894        let job_id = self.job_id();
895        [format!("job_id: {job_id}"), "mode: idle".into(), "steps: []".into()].join("\n")
896    }
897
898    pub fn build_job_yaml(&self) -> Result<String, String> {
899        if self.stops.is_empty() {
900            return Err("add at least one stop (waypoint, harvest node, or deposit)".into());
901        }
902        let job_id = self.job_id();
903        let mut lines = vec![
904            format!("job_id: {job_id}"),
905            "mode: job_loop".into(),
906            "route:".into(),
907            "  kind: ordered".into(),
908        ];
909        if let Some(lodging) = &self.lodging_container_id {
910            lines.push(format!("  lodging_container_id: {lodging}"));
911        }
912        lines.push(format!(
913            "  carry_return_ratio: {:.2}",
914            self.carry_return_ratio
915        ));
916        lines.push("  stops:".into());
917        for stop in &self.stops {
918            match stop {
919                WorkerRouteStop::Waypoint { x, y, z } => {
920                    lines.push(format!(
921                        "    - {{ stop: waypoint, x: {:.1}, y: {:.1}, z: {:.1} }}",
922                        x, y, z
923                    ));
924                }
925                WorkerRouteStop::HarvestNode { node_id } => {
926                    lines.push(format!("    - {{ stop: harvest_node, node_id: {node_id} }}"));
927                }
928                WorkerRouteStop::DepositAt { container_id, filter } => {
929                    let f = filter
930                        .as_ref()
931                        .filter(|f| !f.is_empty())
932                        .map(|f| format!(", filter: [{}]", f.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")))
933                        .unwrap_or_default();
934                    lines.push(format!(
935                        "    - {{ stop: deposit_at, container_id: {container_id}{f} }}"
936                    ));
937                }
938                WorkerRouteStop::RestIfNeeded => {
939                    lines.push("    - { stop: rest_if_needed }".into());
940                }
941                WorkerRouteStop::Wait { wait_ticks } => {
942                    lines.push(format!("    - {{ stop: wait, wait_ticks: {wait_ticks} }}"));
943                }
944                WorkerRouteStop::TradeWith {
945                    npc_id,
946                    template,
947                    sell_all,
948                } => {
949                    let who = npc_id
950                        .as_deref()
951                        .map(|n| format!(", npc_id: {n}"))
952                        .unwrap_or_default();
953                    lines.push(format!(
954                        "    - {{ stop: trade_with, template: {template}{who}, sell_all: {sell_all} }}"
955                    ));
956                }
957                WorkerRouteStop::WithdrawFrom { container_id, items } => {
958                    let mut block = format!(
959                        "    - stop: withdraw_from\n      container_id: {container_id}\n      items:"
960                    );
961                    for it in items {
962                        let line = match it.qty {
963                            None => format!("\n        - {{ template: {}, all: true }}", it.template),
964                            Some(q) => format!("\n        - {{ template: {}, qty: {} }}", it.template, q),
965                        };
966                        block.push_str(&line);
967                    }
968                    lines.push(block);
969                }
970                WorkerRouteStop::CraftAt {
971                    device,
972                    blueprint,
973                    qty,
974                } => {
975                    let qty_str = qty
976                        .map(|q| format!(", qty: {q}"))
977                        .unwrap_or_default();
978                    lines.push(format!(
979                        "    - {{ stop: craft_at, device: {device}, blueprint: {blueprint}{qty_str} }}"
980                    ));
981                }
982            }
983        }
984        lines.push("steps: []".into());
985        Ok(lines.join("\n"))
986    }
987
988    /// Build a protocol route view matching the current draft (for optimistic client UI).
989    pub fn to_route_view(&self) -> flatland_protocol::WorkerRouteView {
990        use flatland_protocol::{
991            WorkerRouteKindView, WorkerRouteStopView, WorkerRouteView, WorkerWithdrawItemView,
992        };
993        WorkerRouteView {
994            kind: WorkerRouteKindView::Ordered,
995            lodging_container_id: self.lodging_container_id.clone(),
996            outbound_waypoints: Vec::new(),
997            harvest_nodes: Vec::new(),
998            carry_return_ratio: self.carry_return_ratio,
999            stops: self
1000                .stops
1001                .iter()
1002                .map(|stop| match stop {
1003                    WorkerRouteStop::Waypoint { x, y, z } => WorkerRouteStopView::Waypoint {
1004                        x: *x,
1005                        y: *y,
1006                        z: *z,
1007                    },
1008                    WorkerRouteStop::HarvestNode { node_id } => WorkerRouteStopView::HarvestNode {
1009                        node_id: node_id.clone(),
1010                    },
1011                    WorkerRouteStop::DepositAt { container_id, filter } => {
1012                        WorkerRouteStopView::DepositAt {
1013                            container_id: container_id.clone(),
1014                            filter: filter.clone(),
1015                        }
1016                    }
1017                    WorkerRouteStop::TradeWith {
1018                        npc_id,
1019                        template,
1020                        sell_all,
1021                    } => WorkerRouteStopView::TradeWith {
1022                        npc_id: npc_id.clone(),
1023                        template: template.clone(),
1024                        sell_all: *sell_all,
1025                    },
1026                    WorkerRouteStop::WithdrawFrom { container_id, items } => {
1027                        WorkerRouteStopView::WithdrawFrom {
1028                            container_id: container_id.clone(),
1029                            items: items
1030                                .iter()
1031                                .map(|i| WorkerWithdrawItemView {
1032                                    template: i.template.clone(),
1033                                    qty: i.qty.unwrap_or(0),
1034                                    all: i.qty.is_none(),
1035                                })
1036                                .collect(),
1037                        }
1038                    }
1039                    WorkerRouteStop::CraftAt {
1040                        device,
1041                        blueprint,
1042                        qty,
1043                    } => WorkerRouteStopView::CraftAt {
1044                        device: device.clone(),
1045                        blueprint: blueprint.clone(),
1046                        qty: *qty,
1047                    },
1048                    WorkerRouteStop::RestIfNeeded => WorkerRouteStopView::RestIfNeeded,
1049                    WorkerRouteStop::Wait { wait_ticks } => WorkerRouteStopView::Wait {
1050                        wait_ticks: *wait_ticks,
1051                    },
1052                })
1053                .collect(),
1054        }
1055    }
1056}
1057
1058fn stop_view_to_stop(view: &WorkerRouteStopView) -> WorkerRouteStop {
1059    match view {
1060        WorkerRouteStopView::Waypoint { x, y, z } => WorkerRouteStop::Waypoint {
1061            x: *x,
1062            y: *y,
1063            z: *z,
1064        },
1065        WorkerRouteStopView::HarvestNode { node_id } => WorkerRouteStop::HarvestNode {
1066            node_id: node_id.clone(),
1067        },
1068        WorkerRouteStopView::DepositAt { container_id, filter } => WorkerRouteStop::DepositAt {
1069            container_id: container_id.clone(),
1070            filter: filter.clone(),
1071        },
1072        WorkerRouteStopView::TradeWith {
1073            npc_id,
1074            template,
1075            sell_all,
1076        } => WorkerRouteStop::TradeWith {
1077            npc_id: npc_id.clone(),
1078            template: template.clone(),
1079            sell_all: *sell_all,
1080        },
1081        WorkerRouteStopView::WithdrawFrom { container_id, items } => WorkerRouteStop::WithdrawFrom {
1082            container_id: container_id.clone(),
1083            items: items
1084                .iter()
1085                .map(|i| WorkerRouteWithdrawItem {
1086                    template: i.template.clone(),
1087                    qty: if i.all { None } else { Some(i.qty) },
1088                })
1089                .collect(),
1090        },
1091        WorkerRouteStopView::CraftAt {
1092            device,
1093            blueprint,
1094            qty,
1095        } => WorkerRouteStop::CraftAt {
1096            device: device.clone(),
1097            blueprint: blueprint.clone(),
1098            qty: *qty,
1099        },
1100        WorkerRouteStopView::RestIfNeeded => WorkerRouteStop::RestIfNeeded,
1101        WorkerRouteStopView::Wait { wait_ticks } => WorkerRouteStop::Wait {
1102            wait_ticks: *wait_ticks,
1103        },
1104    }
1105}
1106
1107// ---- map-click picking ----------------------------------------------
1108
1109const HARVEST_NODE_PICK_M: f32 = 4.0;
1110const LODGING_PICK_M: f32 = 5.0;
1111const STORAGE_PICK_M: f32 = 5.0;
1112
1113fn dist2d(x0: f32, y0: f32, x1: f32, y1: f32) -> f32 {
1114    let dx = x0 - x1;
1115    let dy = y0 - y1;
1116    (dx * dx + dy * dy).sqrt()
1117}
1118
1119/// Nearest harvestable resource node within click range.
1120pub fn pick_resource_node_at<'a>(
1121    nodes: &'a [ResourceNodeView],
1122    x: f32,
1123    y: f32,
1124) -> Option<&'a ResourceNodeView> {
1125    nodes
1126        .iter()
1127        .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
1128        .filter_map(|n| {
1129            let d = dist2d(x, y, n.x, n.y);
1130            if d <= HARVEST_NODE_PICK_M {
1131                Some((d, n))
1132            } else {
1133                None
1134            }
1135        })
1136        .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1137        .map(|(_, n)| n)
1138}
1139
1140/// Owned placed lodging containers (camp beds) — used as the rest/bed target.
1141pub fn owned_lodging_container_ids(
1142    placed: &[PlacedContainerView],
1143    character_id: Option<uuid::Uuid>,
1144) -> Vec<(String, String)> {
1145    owned_lodging_container_ids_with_occupants(placed, character_id, &[])
1146}
1147
1148/// Lodging beds with display names that include occupant labels (`Camp bed — Elda`).
1149pub fn owned_lodging_container_ids_with_occupants(
1150    placed: &[PlacedContainerView],
1151    character_id: Option<uuid::Uuid>,
1152    hired: &[flatland_protocol::HiredWorkerView],
1153) -> Vec<(String, String)> {
1154    let Some(cid) = character_id else {
1155        return Vec::new();
1156    };
1157    let mut out: Vec<(String, String)> = placed
1158        .iter()
1159        .filter(|c| c.worker_lodging_capacity.unwrap_or(0) > 0)
1160        .filter(|c| c.owner_character_id == Some(cid))
1161        .map(|c| {
1162            let who = lodging_occupants_for(hired, &c.id);
1163            let name = if who.is_empty() {
1164                format!("{} — vacant", c.display_name)
1165            } else {
1166                format!("{} — {}", c.display_name, who.join(", "))
1167            };
1168            (c.id.clone(), name)
1169        })
1170        .collect();
1171    out.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
1172    out
1173}
1174
1175pub fn pick_lodging_container_at(
1176    placed: &[PlacedContainerView],
1177    character_id: Option<uuid::Uuid>,
1178    x: f32,
1179    y: f32,
1180) -> Option<String> {
1181    let cid = character_id?;
1182    placed
1183        .iter()
1184        .filter(|c| c.worker_lodging_capacity.unwrap_or(0) > 0)
1185        .filter(|c| c.owner_character_id == Some(cid))
1186        .filter_map(|c| {
1187            let d = dist2d(x, y, c.x, c.y);
1188            if d <= LODGING_PICK_M {
1189                Some((d, c.id.clone()))
1190            } else {
1191                None
1192            }
1193        })
1194        .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1195        .map(|(_, id)| id)
1196}
1197
1198/// Nearest trade-capable NPC within click range.
1199pub fn pick_trade_npc_at(
1200    npcs: &[NpcView],
1201    x: f32,
1202    y: f32,
1203) -> Option<(String, String)> {
1204    const NPC_PICK_M: f32 = 5.0;
1205    npcs.iter()
1206        .filter(|n| n.can_trade)
1207        .filter_map(|n| {
1208            let d = dist2d(x, y, n.x, n.y);
1209            if d <= NPC_PICK_M {
1210                Some((d, n.id.clone(), n.label.clone()))
1211            } else {
1212                None
1213            }
1214        })
1215        .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1216        .map(|(_, id, label)| (id, label))
1217}
1218
1219/// Distinct item templates present in the employer's owned placed containers —
1220/// candidate sell templates for `TradeWith` stops.
1221pub fn owned_storage_template_ids(
1222    placed: &[PlacedContainerView],
1223    character_id: Option<uuid::Uuid>,
1224) -> Vec<String> {
1225    let Some(cid) = character_id else {
1226        return Vec::new();
1227    };
1228    let mut out: Vec<String> = placed
1229        .iter()
1230        .filter(|c| c.owner_character_id == Some(cid))
1231        .flat_map(|c| c.contents.iter().map(|s| s.template_id.clone()))
1232        .filter(|t| !t.is_empty())
1233        .collect();
1234    out.sort();
1235    out.dedup();
1236    out
1237}
1238
1239/// Blueprint ids the route editor may assign to a `craft_at hand` stop.
1240///
1241/// Order matches `blueprints`. When the worker has a non-empty
1242/// `known_blueprint_ids` list, only those recipes are included — the picker UI
1243/// and j/k navigation must use the same list so the cursor cannot land on
1244/// recipes the worker does not know (and so Enter confirms the highlighted row).
1245pub fn worker_craft_blueprint_ids(
1246    blueprints: &[flatland_protocol::BlueprintView],
1247    known_blueprint_ids: Option<&[String]>,
1248) -> Vec<String> {
1249    let mut ids: Vec<String> = blueprints.iter().map(|b| b.id.clone()).collect();
1250    if let Some(known) = known_blueprint_ids {
1251        if !known.is_empty() {
1252            ids.retain(|id| known.iter().any(|k| k == id));
1253        }
1254    }
1255    ids
1256}
1257
1258/// Item templates for deposit filters / sell stops — not limited to what is
1259/// already in a chest. Includes storage, inventory, craft outputs/inputs, harvest
1260/// node products, and any `extra` seeds (existing filter selections, route craft
1261/// outputs) so factory lines can be authored before the first batch is made.
1262pub fn route_item_template_candidates(
1263    placed: &[PlacedContainerView],
1264    character_id: Option<uuid::Uuid>,
1265    inventory: &std::collections::HashMap<String, u32>,
1266    blueprints: &[flatland_protocol::BlueprintView],
1267    resource_nodes: &[flatland_protocol::ResourceNodeView],
1268    extra: &[String],
1269) -> Vec<String> {
1270    let mut out = owned_storage_template_ids(placed, character_id);
1271    for (template, qty) in inventory {
1272        if *qty > 0 && !template.is_empty() {
1273            out.push(template.clone());
1274        }
1275    }
1276    for bp in blueprints {
1277        if !bp.output.is_empty() {
1278            out.push(bp.output.clone());
1279        }
1280        for input in &bp.inputs {
1281            if !input.template_id.is_empty() {
1282                out.push(input.template_id.clone());
1283            }
1284        }
1285        for tool in &bp.required_tools {
1286            if !tool.item.is_empty() {
1287                out.push(tool.item.clone());
1288            }
1289        }
1290    }
1291    for node in resource_nodes {
1292        if !node.item_template.is_empty() {
1293            out.push(node.item_template.clone());
1294        }
1295    }
1296    for t in extra {
1297        if !t.is_empty() {
1298            out.push(t.clone());
1299        }
1300    }
1301    out.sort();
1302    out.dedup();
1303    out
1304}
1305
1306/// Pick any owned storage container at a click position (lodging or regular chest).
1307pub fn pick_storage_container_at(
1308    placed: &[PlacedContainerView],
1309    character_id: Option<uuid::Uuid>,
1310    x: f32,
1311    y: f32,
1312) -> Option<String> {
1313    let cid = character_id?;
1314    placed
1315        .iter()
1316        .filter(|c| c.owner_character_id == Some(cid))
1317        .filter(|c| c.capacity_volume.unwrap_or(0.0) > 0.0)
1318        .filter_map(|c| {
1319            let d = dist2d(x, y, c.x, c.y);
1320            if d <= STORAGE_PICK_M {
1321                Some((d, c.id.clone()))
1322            } else {
1323                None
1324            }
1325        })
1326        .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
1327        .map(|(_, id)| id)
1328}
1329
1330#[cfg(test)]
1331mod tests {
1332    use super::*;
1333
1334    #[test]
1335    fn worker_craft_blueprint_ids_filters_to_known_recipes() {
1336        use flatland_protocol::{BlueprintIngredientView, BlueprintView};
1337
1338        fn bp(id: &str) -> BlueprintView {
1339            BlueprintView {
1340                id: id.into(),
1341                label: id.into(),
1342                output: "x".into(),
1343                output_qty: 1,
1344                craft_ticks: 1,
1345                inputs: vec![BlueprintIngredientView {
1346                    template_id: "oak_log".into(),
1347                    quantity: 1,
1348                    consumed: true,
1349                }],
1350                station: Some("hand".into()),
1351                category: None,
1352                required_tools: vec![],
1353                skill: None,
1354                failure_chance: 0.0,
1355                worker_train_copper: 0,
1356            }
1357        }
1358
1359        let all = vec![
1360            bp("oak_to_lumber"),
1361            bp("vegetable_soup"),
1362            bp("craft_simple_camp_bed"),
1363            bp("craft_wooden_chest_small"),
1364            bp("iron_ingot"),
1365        ];
1366        let known = vec![
1367            "oak_to_lumber".into(),
1368            "craft_simple_camp_bed".into(),
1369            "craft_wooden_chest_small".into(),
1370        ];
1371
1372        let filtered = worker_craft_blueprint_ids(&all, Some(&known));
1373        assert_eq!(
1374            filtered,
1375            vec![
1376                "oak_to_lumber",
1377                "craft_simple_camp_bed",
1378                "craft_wooden_chest_small"
1379            ]
1380        );
1381
1382        // Empty known list = no filter (legacy / unknown worker).
1383        assert_eq!(
1384            worker_craft_blueprint_ids(&all, Some(&[])).len(),
1385            all.len()
1386        );
1387        assert_eq!(worker_craft_blueprint_ids(&all, None).len(), all.len());
1388    }
1389
1390    #[test]
1391    fn route_item_candidates_include_craft_outputs_not_in_storage() {
1392        use flatland_protocol::{
1393            BlueprintIngredientView, BlueprintView, PlacedContainerView, ResourceNodeView,
1394            ResourceNodeState,
1395        };
1396        use std::collections::HashMap;
1397        use uuid::Uuid;
1398
1399        let cid = Uuid::from_u128(0x1111_2222_3333_4444_5555_6666_7777_8888);
1400        let placed = vec![PlacedContainerView {
1401            id: "chest-1".into(),
1402            template_id: "wooden_chest_small".into(),
1403            display_name: "Chest".into(),
1404            x: 0.0,
1405            y: 0.0,
1406            z: 0.0,
1407            locked: false,
1408            accessible: true,
1409            owner_character_id: Some(cid),
1410            contents: vec![],
1411            lock_id: None,
1412            capacity_volume: Some(20.0),
1413            item_instance_id: None,
1414            tile_id: None,
1415            worker_lodging_capacity: None,
1416        }];
1417        let blueprints = vec![BlueprintView {
1418            id: "smelt_iron".into(),
1419            label: "Smelt Iron".into(),
1420            output: "iron_ingot".into(),
1421            output_qty: 1,
1422            craft_ticks: 30,
1423            inputs: vec![BlueprintIngredientView {
1424                template_id: "iron_ore".into(),
1425                quantity: 1,
1426                consumed: true,
1427            }],
1428            station: Some("hand".into()),
1429            category: None,
1430            required_tools: vec![],
1431            skill: None,
1432            failure_chance: 0.0,
1433            worker_train_copper: 0,
1434        }];
1435        let nodes = vec![ResourceNodeView {
1436            id: "ore-1".into(),
1437            label: "Iron Ore".into(),
1438            x: 1.0,
1439            y: 1.0,
1440            z: 0.0,
1441            item_template: "iron_ore".into(),
1442            state: ResourceNodeState::Available,
1443            blocking: true,
1444            blocking_radius_m: 0.8,
1445            tile_id: None,
1446            sprite_mode: None,
1447            presentation_state: None,
1448        }];
1449        let ids = route_item_template_candidates(
1450            &placed,
1451            Some(cid),
1452            &HashMap::new(),
1453            &blueprints,
1454            &nodes,
1455            &[],
1456        );
1457        assert!(
1458            ids.contains(&"iron_ingot".to_string()),
1459            "craft output should be selectable before any exists in storage: {ids:?}"
1460        );
1461        assert!(ids.contains(&"iron_ore".to_string()));
1462    }
1463
1464    #[test]
1465    fn build_ordered_job_yaml_includes_stops() {
1466        let mut ed = WorkerRouteEditorState::new(
1467            "worker-worker_laborer-1".into(),
1468            "Laborer".into(),
1469            Some("chest-bed".into()),
1470        );
1471        ed.append_waypoint(10.0, 20.0, 0.0);
1472        ed.append_harvest_node("oak-n1");
1473        ed.append_deposit_at("chest-storage-a");
1474        ed.append_rest_if_needed();
1475        let yaml = ed.build_job_yaml().expect("yaml");
1476        assert!(yaml.contains("kind: ordered"));
1477        assert!(yaml.contains("lodging_container_id: chest-bed"));
1478        assert!(yaml.contains("stop: waypoint"));
1479        assert!(yaml.contains("oak-n1"));
1480        assert!(yaml.contains("deposit_at"));
1481        assert!(yaml.contains("chest-storage-a"));
1482        assert!(yaml.contains("rest_if_needed"));
1483    }
1484
1485    #[test]
1486    fn requires_at_least_one_stop() {
1487        let ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1488        assert!(ed.build_job_yaml().is_err());
1489    }
1490
1491    #[test]
1492    fn reorder_stops() {
1493        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1494        ed.append_harvest_node("oak-a");
1495        ed.append_harvest_node("oak-b");
1496        ed.append_waypoint(5.0, 6.0, 0.0);
1497        // select index 1 (oak-b), move up → order becomes oak-b, oak-a, waypoint
1498        ed.select_stop(1);
1499        ed.move_selected_up();
1500        assert!(
1501            matches!(&ed.stops[0], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
1502        );
1503        // move down again restores
1504        ed.move_selected_down();
1505        assert!(
1506            matches!(&ed.stops[1], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
1507        );
1508    }
1509
1510    #[test]
1511    fn duplicate_harvest_node_selects_existing_instead() {
1512        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1513        assert!(ed.append_harvest_node("oak-a"));
1514        ed.append_waypoint(1.0, 2.0, 0.0);
1515        assert!(!ed.append_harvest_node("oak-a"));
1516        assert_eq!(ed.stops.len(), 2);
1517        assert_eq!(ed.selected_stop_index, 0);
1518    }
1519
1520    #[test]
1521    fn duplicate_deposit_container_selects_existing_instead() {
1522        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1523        assert!(ed.append_deposit_at("chest-1"));
1524        ed.append_harvest_node("oak-a");
1525        assert!(!ed.append_deposit_at("chest-1"));
1526        assert_eq!(ed.stops.len(), 2);
1527        assert_eq!(ed.selected_stop_index, 0);
1528    }
1529
1530    #[test]
1531    fn duplicate_trade_stop_selects_existing_instead() {
1532        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1533        assert!(ed.append_trade_with("oak_log".into(), Some("ada".into()), true));
1534        assert!(!ed.append_trade_with("oak_log".into(), Some("ada".into()), true));
1535        // Different template at the same merchant is a distinct stop.
1536        assert!(ed.append_trade_with("lumber".into(), Some("ada".into()), true));
1537        assert_eq!(ed.stops.len(), 2);
1538    }
1539
1540    #[test]
1541    fn build_idle_job_yaml_parks_worker() {
1542        let ed = WorkerRouteEditorState::new("w1".into(), "L".into(), Some("bed-1".into()));
1543        let yaml = ed.build_idle_job_yaml();
1544        assert!(yaml.contains("mode: idle"));
1545        assert!(yaml.contains("steps: []"));
1546        assert!(!yaml.contains("route:"));
1547    }
1548
1549    #[test]
1550    fn remove_selected_stop_adjusts_index() {
1551        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1552        ed.append_waypoint(1.0, 2.0, 0.0);
1553        ed.append_harvest_node("oak-a");
1554        ed.append_deposit_at("chest-1");
1555        ed.select_stop(2);
1556        ed.remove_selected_stop();
1557        assert_eq!(ed.stops.len(), 2);
1558        assert_eq!(ed.selected_stop_index, 1);
1559    }
1560
1561    #[test]
1562    fn build_job_yaml_includes_trade_with_stop() {
1563        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1564        ed.append_trade_with("oak_log".into(), None, true);
1565        ed.append_trade_with("lumber".into(), Some("ada_broker".into()), false);
1566        let yaml = ed.build_job_yaml().expect("yaml");
1567        assert!(yaml.contains("stop: trade_with, template: oak_log, sell_all: true"));
1568        assert!(yaml.contains("npc_id: ada_broker"));
1569        assert!(yaml.contains("sell_all: false"));
1570    }
1571
1572    #[test]
1573    fn set_selected_trade_npc_updates_stop() {
1574        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1575        ed.append_trade_with("oak_log".into(), None, true);
1576        assert!(ed.set_selected_trade_npc("ada_broker".into()));
1577        assert!(matches!(&ed.stops[0], WorkerRouteStop::TradeWith { npc_id, .. } if npc_id.as_deref() == Some("ada_broker")));
1578    }
1579
1580    #[test]
1581    fn build_job_yaml_deposit_filter_round_trips() {
1582        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), Some("bed-1".into()));
1583        ed.append_deposit_at_filtered("chest-out", vec!["lumber".into()]);
1584        let yaml = ed.build_job_yaml().expect("yaml");
1585        assert!(yaml.contains("stop: deposit_at, container_id: chest-out, filter: [lumber]"));
1586    }
1587
1588    #[test]
1589    fn build_job_yaml_includes_withdraw_and_craft_stops() {
1590        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1591        ed.stops.push(WorkerRouteStop::WithdrawFrom {
1592            container_id: "chest-src".into(),
1593            items: vec![WorkerRouteWithdrawItem { template: "oak_log".into(), qty: None }],
1594        });
1595        ed.stops.push(WorkerRouteStop::WithdrawFrom {
1596            container_id: "chest-src-2".into(),
1597            items: vec![WorkerRouteWithdrawItem { template: "iron_ore".into(), qty: Some(10) }],
1598        });
1599        ed.stops.push(WorkerRouteStop::CraftAt {
1600            device: "hand".into(),
1601            blueprint: "oak_to_lumber".into(),
1602            qty: None,
1603        });
1604        ed.append_deposit_at("chest-out");
1605        let yaml = ed.build_job_yaml().expect("yaml");
1606        assert!(yaml.contains("stop: withdraw_from"));
1607        assert!(yaml.contains("container_id: chest-src"));
1608        assert!(yaml.contains("template: oak_log, all: true"));
1609        assert!(yaml.contains("template: iron_ore, qty: 10"));
1610        assert!(yaml.contains("stop: craft_at, device: hand, blueprint: oak_to_lumber"));
1611        assert!(yaml.contains("stop: deposit_at"));
1612    }
1613
1614    #[test]
1615    fn withdraw_summary_shows_all_vs_qty() {
1616        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1617        ed.stops.push(WorkerRouteStop::WithdrawFrom {
1618            container_id: "chest-src".into(),
1619            items: vec![WorkerRouteWithdrawItem { template: "oak_log".into(), qty: None }],
1620        });
1621        assert!(ed.stops[0].summary().contains("withdraw all oak_log"));
1622    }
1623
1624    #[test]
1625    fn withdraw_view_round_trips_all_flag() {
1626        let view = WorkerRouteStopView::WithdrawFrom {
1627            container_id: "chest-1".into(),
1628            items: vec![
1629                flatland_protocol::WorkerWithdrawItemView {
1630                    template: "oak_log".into(),
1631                    qty: 0,
1632                    all: true,
1633                },
1634                flatland_protocol::WorkerWithdrawItemView {
1635                    template: "iron_ore".into(),
1636                    qty: 5,
1637                    all: false,
1638                },
1639            ],
1640        };
1641        let stop = stop_view_to_stop(&view);
1642        let WorkerRouteStop::WithdrawFrom { items, .. } = stop else {
1643            panic!("expected withdraw stop");
1644        };
1645        assert_eq!(items[0].qty, None);
1646        assert_eq!(items[1].qty, Some(5));
1647    }
1648
1649    #[test]
1650    fn legacy_harvest_loop_route_converts_to_ordered_stops() {
1651        let route = WorkerRouteView {
1652            kind: WorkerRouteKindView::HarvestLoop,
1653            lodging_container_id: Some("bed-1".into()),
1654            outbound_waypoints: vec![flatland_protocol::WorkerRouteWaypointView {
1655                x: 1.0,
1656                y: 2.0,
1657                z: 0.0,
1658            }],
1659            harvest_nodes: vec!["oak-1".into()],
1660            carry_return_ratio: 0.9,
1661            stops: Vec::new(),
1662        };
1663        let ed = WorkerRouteEditorState::from_saved_route(
1664            "w1".into(),
1665            "L".into(),
1666            &route,
1667            None,
1668        );
1669        // waypoint, harvest, deposit@bed, rest
1670        assert_eq!(ed.stops.len(), 4);
1671        assert!(matches!(&ed.stops[2], WorkerRouteStop::DepositAt { container_id, .. } if container_id == "bed-1"));
1672        assert!(matches!(&ed.stops[3], WorkerRouteStop::RestIfNeeded));
1673    }
1674
1675    // ---- sheet state machine ------------------------------------------
1676
1677    #[test]
1678    fn retarget_withdraw_container_updates_editing_stop() {
1679        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1680        ed.stops.push(WorkerRouteStop::WithdrawFrom {
1681            container_id: "chest-old".into(),
1682            items: vec![WorkerRouteWithdrawItem {
1683                template: "iron_ore".into(),
1684                qty: None,
1685            }],
1686        });
1687        ed.selected_stop_index = 0;
1688        ed.editing_index = Some(0);
1689        assert!(ed.retarget_withdraw_container("chest-new".into()));
1690        assert!(matches!(
1691            &ed.stops[0],
1692            WorkerRouteStop::WithdrawFrom { container_id, .. } if container_id == "chest-new"
1693        ));
1694    }
1695
1696    #[test]
1697    fn summary_resolved_uses_friendly_labels() {
1698        let stop = WorkerRouteStop::WithdrawFrom {
1699            container_id: "uuid-iron".into(),
1700            items: vec![WorkerRouteWithdrawItem {
1701                template: "iron_ore".into(),
1702                qty: None,
1703            }],
1704        };
1705        let summary = stop.summary_resolved(
1706            |_| "Iron Ore Container".into(),
1707            |_| "Ada".into(),
1708            |_| "Oak Tree".into(),
1709        );
1710        assert_eq!(summary, "withdraw all iron_ore from Iron Ore Container");
1711        assert!(!summary.contains("uuid"));
1712    }
1713
1714    #[test]
1715    fn sheet_back_walks_up_hierarchy() {
1716        use RouteEditorSheet as S;
1717        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1718        assert_eq!(ed.sheet, S::Stops);
1719        ed.open_add_menu();
1720        assert_eq!(ed.sheet, S::AddMenu { index: 0 });
1721        ed.open_sheet(S::WithdrawContainers { index: 0 });
1722        ed.open_sheet(S::WithdrawItems {
1723            container_id: "c1".into(),
1724            lines: vec![],
1725            index: 0,
1726        });
1727        ed.sheet_back();
1728        assert_eq!(ed.sheet, S::WithdrawContainers { index: 0 });
1729        ed.sheet_back();
1730        assert_eq!(ed.sheet, S::AddMenu { index: 0 });
1731        ed.sheet_back();
1732        assert_eq!(ed.sheet, S::Stops);
1733        // Root: back is a no-op.
1734        ed.sheet_back();
1735        assert_eq!(ed.sheet, S::Stops);
1736    }
1737
1738    #[test]
1739    fn sheet_back_while_editing_returns_to_stops() {
1740        use RouteEditorSheet as S;
1741        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1742        ed.append_harvest_node("oak-a");
1743        ed.begin_edit_selected();
1744        ed.open_sheet(S::HarvestPicker { index: 0 });
1745        ed.sheet_back();
1746        assert_eq!(ed.sheet, S::Stops);
1747        assert_eq!(ed.editing_index, None);
1748    }
1749
1750    #[test]
1751    fn sheet_back_while_editing_withdraw_keeps_edit_on_container_picker() {
1752        use RouteEditorSheet as S;
1753        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1754        ed.stops.push(WorkerRouteStop::WithdrawFrom {
1755            container_id: "chest-a".into(),
1756            items: vec![WorkerRouteWithdrawItem {
1757                template: "oak_log".into(),
1758                qty: None,
1759            }],
1760        });
1761        ed.begin_edit_selected();
1762        ed.open_sheet(S::WithdrawItems {
1763            container_id: "chest-a".into(),
1764            lines: vec![],
1765            index: 0,
1766        });
1767        ed.sheet_back();
1768        assert!(matches!(ed.sheet, S::WithdrawContainers { .. }));
1769        assert_eq!(ed.editing_index, Some(0), "still editing after back to picker");
1770        ed.sheet_back();
1771        assert_eq!(ed.sheet, S::Stops);
1772        assert_eq!(ed.editing_index, None);
1773    }
1774
1775    #[test]
1776    fn confirm_stop_replaces_withdraw_container_when_editing() {
1777        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1778        ed.stops.push(WorkerRouteStop::WithdrawFrom {
1779            container_id: "chest-old".into(),
1780            items: vec![WorkerRouteWithdrawItem {
1781                template: "oak_log".into(),
1782                qty: None,
1783            }],
1784        });
1785        ed.stops.push(WorkerRouteStop::RestIfNeeded);
1786        ed.select_stop(0);
1787        ed.begin_edit_selected();
1788        assert!(ed.confirm_stop(WorkerRouteStop::WithdrawFrom {
1789            container_id: "chest-new".into(),
1790            items: vec![WorkerRouteWithdrawItem {
1791                template: "oak_log".into(),
1792                qty: None,
1793            }],
1794        }));
1795        assert_eq!(ed.stops.len(), 2);
1796        assert!(matches!(
1797            &ed.stops[0],
1798            WorkerRouteStop::WithdrawFrom { container_id, .. } if container_id == "chest-new"
1799        ));
1800    }
1801
1802    #[test]
1803    fn confirm_stop_replaces_when_editing() {
1804        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1805        ed.append_harvest_node("oak-a");
1806        ed.append_waypoint(1.0, 1.0, 0.0);
1807        ed.select_stop(0);
1808        ed.begin_edit_selected();
1809        assert!(ed.confirm_stop(WorkerRouteStop::HarvestNode {
1810            node_id: "oak-b".into()
1811        }));
1812        assert_eq!(ed.stops.len(), 2, "edit replaces in place, no append");
1813        assert!(
1814            matches!(&ed.stops[0], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
1815        );
1816        assert_eq!(ed.sheet, RouteEditorSheet::Stops);
1817        assert_eq!(ed.editing_index, None);
1818    }
1819
1820    #[test]
1821    fn confirm_stop_dedupes_on_append() {
1822        let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
1823        ed.append_harvest_node("oak-a");
1824        assert!(!ed.confirm_stop(WorkerRouteStop::HarvestNode {
1825            node_id: "oak-a".into()
1826        }));
1827        assert_eq!(ed.stops.len(), 1);
1828        assert_eq!(ed.selected_stop_index, 0);
1829    }
1830
1831    #[test]
1832    fn withdraw_line_cycle_and_collect() {
1833        let contents = vec![
1834            ItemStack {
1835                template_id: "oak_log".into(),
1836                quantity: 12,
1837                ..Default::default()
1838            },
1839            ItemStack {
1840                template_id: "lumber".into(),
1841                quantity: 4,
1842                ..Default::default()
1843            },
1844        ];
1845        let mut lines =
1846            WorkerRouteEditorState::withdraw_line_drafts(&contents, &[]);
1847        assert_eq!(lines.len(), 2);
1848        // cycle oak_log (sorted first: lumber, oak_log)
1849        lines[1].cycle();
1850        assert_eq!(lines[1].mode, WithdrawLineMode::All);
1851        lines[0].cycle();
1852        lines[0].cycle();
1853        assert!(matches!(lines[0].mode, WithdrawLineMode::Qty(_)));
1854        lines[0].adjust_qty(5);
1855        let items = WorkerRouteEditorState::withdraw_items_from_lines(&lines);
1856        assert_eq!(items.len(), 2);
1857        assert_eq!(items[0].template, "lumber");
1858        // lumber available = 4: Qty starts at 4, +5 clamps back to 4.
1859        assert_eq!(items[0].qty, Some(4));
1860        assert_eq!(items[1].qty, None);
1861    }
1862
1863    #[test]
1864    fn withdraw_drafts_prefill_existing_and_keep_missing() {
1865        let contents = vec![ItemStack {
1866            template_id: "oak_log".into(),
1867            quantity: 3,
1868            ..Default::default()
1869        }];
1870        let existing = vec![
1871            WorkerRouteWithdrawItem { template: "oak_log".into(), qty: None },
1872            WorkerRouteWithdrawItem { template: "iron_ore".into(), qty: Some(5) },
1873        ];
1874        let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
1875        assert_eq!(lines.len(), 2);
1876        let ore = lines.iter().find(|l| l.template == "iron_ore").expect("ore line");
1877        assert_eq!(ore.available, 0, "missing template kept with 0 available");
1878        assert_eq!(ore.mode, WithdrawLineMode::Qty(5));
1879        let oak = lines.iter().find(|l| l.template == "oak_log").expect("oak line");
1880        assert_eq!(oak.mode, WithdrawLineMode::All);
1881    }
1882}