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