use flatland_protocol::{
ItemStack, NpcView, PlacedContainerView, ResourceNodeView, WorkerRouteKindView,
WorkerRouteStopView, WorkerRouteView,
};
#[derive(Debug, Clone, PartialEq)]
pub struct WorkerRouteWaypoint {
pub x: f32,
pub y: f32,
pub z: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub enum WorkerRouteStop {
Waypoint { x: f32, y: f32, z: f32 },
HarvestNode { node_id: String },
DepositAt {
container_id: String,
filter: Option<Vec<String>>,
},
TradeWith {
npc_id: Option<String>,
template: String,
sell_all: bool,
},
WithdrawFrom {
container_id: String,
items: Vec<WorkerRouteWithdrawItem>,
},
CraftAt {
device: String,
blueprint: String,
qty: Option<u32>,
},
RestIfNeeded,
Wait { wait_ticks: u64 },
}
#[derive(Debug, Clone, PartialEq)]
pub struct WorkerRouteWithdrawItem {
pub template: String,
pub qty: Option<u32>,
}
impl WorkerRouteStop {
pub fn summary(&self) -> String {
self.summary_resolved(|id| short_id(id), |id| id.to_string(), |id| id.to_string())
}
pub fn summary_resolved(
&self,
container_label: impl Fn(&str) -> String,
npc_label: impl Fn(&str) -> String,
node_label: impl Fn(&str) -> String,
) -> String {
match self {
Self::Waypoint { x, y, .. } => format!("waypoint ({x:.0}, {y:.0})"),
Self::HarvestNode { node_id } => format!("harvest {}", node_label(node_id)),
Self::DepositAt { container_id, filter } => {
let f = filter
.as_ref()
.map(|f| format!(" only {}", f.join(",")))
.unwrap_or_default();
format!("deposit at {}{f}", container_label(container_id))
}
Self::TradeWith { npc_id, template, .. } => {
let who = npc_id
.as_deref()
.map(|id| npc_label(id))
.unwrap_or_else(|| "nearest buyer".into());
format!("sell {template} to {who}")
}
Self::WithdrawFrom { container_id, items } => {
let what = items
.iter()
.map(|i| match i.qty {
None => format!("all {}", i.template),
Some(q) => format!("{q} {}", i.template),
})
.collect::<Vec<_>>()
.join(" + ");
format!("withdraw {what} from {}", container_label(container_id))
}
Self::CraftAt { blueprint, .. } => format!("craft {blueprint}"),
Self::RestIfNeeded => "rest if needed".into(),
Self::Wait { wait_ticks } => format!("wait {wait_ticks}t"),
}
}
pub fn kind_label(&self) -> &'static str {
match self {
Self::Waypoint { .. } => "waypoint",
Self::HarvestNode { .. } => "harvest",
Self::DepositAt { .. } => "deposit",
Self::TradeWith { .. } => "sell",
Self::WithdrawFrom { .. } => "withdraw",
Self::CraftAt { .. } => "craft",
Self::RestIfNeeded => "rest",
Self::Wait { .. } => "wait",
}
}
}
fn short_id(id: &str) -> String {
id.rsplit('-')
.next()
.filter(|s| !s.is_empty())
.unwrap_or(id)
.to_string()
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum WithdrawLineMode {
Off,
All,
Qty(u32),
}
#[derive(Debug, Clone, PartialEq)]
pub struct WithdrawLineDraft {
pub template: String,
pub available: u32,
pub mode: WithdrawLineMode,
}
impl WithdrawLineDraft {
pub fn cycle(&mut self) {
self.mode = match self.mode {
WithdrawLineMode::Off => WithdrawLineMode::All,
WithdrawLineMode::All => WithdrawLineMode::Qty(self.available.clamp(1, 10)),
WithdrawLineMode::Qty(_) => WithdrawLineMode::Off,
};
}
pub fn adjust_qty(&mut self, delta: i32) {
let cur = match self.mode {
WithdrawLineMode::Off => self.available.clamp(1, 10),
WithdrawLineMode::All => self.available.clamp(1, 10),
WithdrawLineMode::Qty(q) => q,
};
let next = (cur as i32 + delta).clamp(1, self.available.max(1) as i32) as u32;
self.mode = WithdrawLineMode::Qty(next);
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum RouteEditorSheet {
Stops,
AddMenu { index: usize },
WaypointMenu { index: usize },
WaypointMapPick,
HarvestPicker { index: usize },
WithdrawContainers { index: usize },
WithdrawItems {
container_id: String,
lines: Vec<WithdrawLineDraft>,
index: usize,
},
DepositContainers { index: usize },
DepositFilter {
container_id: String,
rows: Vec<(String, bool)>,
index: usize,
},
SellNpcs { index: usize },
SellItem {
npc_id: Option<String>,
templates: Vec<String>,
index: usize,
sell_all: bool,
},
CraftBlueprint { index: usize },
WaitEntry { ticks: u64 },
BedPicker { index: usize },
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum RouteEditorClick {
SelectStop(usize),
OpenBedPicker,
SheetRow(usize),
TogglePanel,
}
pub const ADD_MENU: &[&str] = &[
"Waypoint",
"Harvest node",
"Withdraw from storage",
"Deposit to storage",
"Sell to merchant",
"Craft (at hand)",
"Rest if needed",
"Wait",
];
pub const WAYPOINT_MENU: &[&str] = &["At player position", "Pick on map (click)"];
#[derive(Debug, Clone, PartialEq)]
pub struct ContainerCandidate {
pub id: String,
pub name: String,
pub is_lodging: bool,
pub summary: String,
pub dist: f32,
}
pub fn summarize_contents(contents: &[ItemStack]) -> String {
let mut totals: Vec<(String, u32)> = Vec::new();
for s in contents {
if s.template_id.is_empty() {
continue;
}
match totals.iter_mut().find(|(t, _)| t == &s.template_id) {
Some((_, q)) => *q += s.quantity,
None => totals.push((s.template_id.clone(), s.quantity)),
}
}
if totals.is_empty() {
return "(empty)".into();
}
totals.sort();
totals
.iter()
.map(|(t, q)| format!("{t} ×{q}"))
.collect::<Vec<_>>()
.join(" · ")
}
pub fn owned_container_candidates(
placed: &[PlacedContainerView],
character_id: Option<uuid::Uuid>,
px: f32,
py: f32,
) -> Vec<ContainerCandidate> {
owned_container_candidates_with_occupants(placed, character_id, px, py, &[])
}
pub fn owned_container_candidates_with_occupants(
placed: &[PlacedContainerView],
character_id: Option<uuid::Uuid>,
px: f32,
py: f32,
hired: &[flatland_protocol::HiredWorkerView],
) -> Vec<ContainerCandidate> {
let Some(cid) = character_id else {
return Vec::new();
};
let mut out: Vec<ContainerCandidate> = placed
.iter()
.filter(|c| c.owner_character_id == Some(cid))
.filter(|c| {
c.capacity_volume.unwrap_or(0.0) > 0.0 || c.worker_lodging_capacity.unwrap_or(0) > 0
})
.map(|c| {
let is_lodging = c.worker_lodging_capacity.unwrap_or(0) > 0;
let mut summary = summarize_contents(&c.contents);
if is_lodging {
let who = lodging_occupants_for(hired, &c.id);
let who = if who.is_empty() {
"vacant".into()
} else {
who.join(", ")
};
summary = format!("lodged: {who} · {summary}");
}
ContainerCandidate {
id: c.id.clone(),
name: c.display_name.clone(),
is_lodging,
summary,
dist: dist2d(px, py, c.x, c.y),
}
})
.collect();
out.sort_by(|a, b| {
a.dist
.partial_cmp(&b.dist)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.name.cmp(&b.name))
.then_with(|| a.id.cmp(&b.id))
});
out
}
fn lodging_occupants_for(
hired: &[flatland_protocol::HiredWorkerView],
container_id: &str,
) -> Vec<String> {
let mut names: Vec<String> = hired
.iter()
.filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
.map(|w| w.label.clone())
.collect();
names.sort();
names
}
#[derive(Debug, Clone, PartialEq)]
pub struct NodeCandidate {
pub id: String,
pub label: String,
pub template: String,
pub dist: f32,
}
pub fn node_candidates(nodes: &[ResourceNodeView], px: f32, py: f32) -> Vec<NodeCandidate> {
let mut out: Vec<NodeCandidate> = nodes
.iter()
.filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
.map(|n| NodeCandidate {
id: n.id.clone(),
label: n.label.clone(),
template: n.item_template.clone(),
dist: dist2d(px, py, n.x, n.y),
})
.collect();
out.sort_by(|a, b| {
a.dist
.partial_cmp(&b.dist)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.id.cmp(&b.id))
});
out
}
#[derive(Debug, Clone, PartialEq)]
pub struct TradeNpcCandidate {
pub id: String,
pub label: String,
pub dist: f32,
}
pub fn trade_npc_candidates(npcs: &[NpcView], px: f32, py: f32) -> Vec<TradeNpcCandidate> {
let mut out: Vec<TradeNpcCandidate> = npcs
.iter()
.filter(|n| n.can_trade)
.map(|n| TradeNpcCandidate {
id: n.id.clone(),
label: n.label.clone(),
dist: dist2d(px, py, n.x, n.y),
})
.collect();
out.sort_by(|a, b| {
a.dist
.partial_cmp(&b.dist)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.id.cmp(&b.id))
});
out
}
#[derive(Debug, Clone)]
pub struct WorkerRouteEditorState {
pub worker_instance_id: String,
pub worker_label: String,
pub lodging_container_id: Option<String>,
pub stops: Vec<WorkerRouteStop>,
pub selected_stop_index: usize,
pub carry_return_ratio: f32,
pub sheet: RouteEditorSheet,
pub editing_index: Option<usize>,
pub panel_collapsed: bool,
}
impl WorkerRouteEditorState {
pub fn new(
worker_instance_id: String,
worker_label: String,
lodging_container_id: Option<String>,
) -> Self {
Self {
worker_instance_id,
worker_label,
lodging_container_id,
stops: Vec::new(),
selected_stop_index: 0,
carry_return_ratio: 0.90,
sheet: RouteEditorSheet::Stops,
editing_index: None,
panel_collapsed: false,
}
}
pub fn toggle_panel_collapsed(&mut self) {
self.panel_collapsed = !self.panel_collapsed;
}
pub fn from_saved_route(
worker_instance_id: String,
worker_label: String,
route: &WorkerRouteView,
lodging_fallback: Option<String>,
) -> Self {
let lodging = route
.lodging_container_id
.clone()
.or(lodging_fallback);
match route.kind {
WorkerRouteKindView::Ordered => Self {
worker_instance_id,
worker_label,
lodging_container_id: lodging,
stops: route
.stops
.iter()
.map(stop_view_to_stop)
.collect(),
selected_stop_index: 0,
carry_return_ratio: route.carry_return_ratio,
sheet: RouteEditorSheet::Stops,
editing_index: None,
panel_collapsed: false,
},
WorkerRouteKindView::HarvestLoop => {
let mut stops = Vec::new();
for wp in &route.outbound_waypoints {
stops.push(WorkerRouteStop::Waypoint {
x: wp.x,
y: wp.y,
z: wp.z,
});
}
for node in &route.harvest_nodes {
stops.push(WorkerRouteStop::HarvestNode {
node_id: node.clone(),
});
}
if let Some(lodging) = &lodging {
stops.push(WorkerRouteStop::DepositAt {
container_id: lodging.clone(),
filter: None,
});
stops.push(WorkerRouteStop::RestIfNeeded);
}
Self {
worker_instance_id,
worker_label,
lodging_container_id: lodging,
stops,
selected_stop_index: 0,
carry_return_ratio: route.carry_return_ratio,
sheet: RouteEditorSheet::Stops,
editing_index: None,
panel_collapsed: false,
}
}
}
}
pub fn stop_count(&self) -> usize {
self.stops.len()
}
pub fn select_stop(&mut self, index: usize) {
if self.stops.is_empty() {
self.selected_stop_index = 0;
return;
}
self.selected_stop_index = index.min(self.stops.len() - 1);
}
pub fn move_selected_up(&mut self) {
if self.selected_stop_index == 0 {
return;
}
self.stops
.swap(self.selected_stop_index, self.selected_stop_index - 1);
self.selected_stop_index -= 1;
}
pub fn move_selected_down(&mut self) {
if self.selected_stop_index + 1 >= self.stops.len() {
return;
}
self.stops
.swap(self.selected_stop_index, self.selected_stop_index + 1);
self.selected_stop_index += 1;
}
pub fn remove_selected_stop(&mut self) {
if self.stops.is_empty() {
return;
}
let idx = self.selected_stop_index.min(self.stops.len() - 1);
self.stops.remove(idx);
self.editing_index = None;
if self.selected_stop_index >= self.stops.len() {
self.selected_stop_index = self.stops.len().saturating_sub(1);
}
}
fn find_stop(&self, pred: impl Fn(&WorkerRouteStop) -> bool) -> Option<usize> {
self.stops.iter().position(pred)
}
pub fn harvest_node_index(&self, node_id: &str) -> Option<usize> {
self.find_stop(
|s| matches!(s, WorkerRouteStop::HarvestNode { node_id: n } if n == node_id),
)
}
pub fn deposit_container_index(&self, container_id: &str) -> Option<usize> {
self.find_stop(
|s| matches!(s, WorkerRouteStop::DepositAt { container_id: c, .. } if c == container_id),
)
}
pub fn trade_stop_index(&self, npc_id: Option<&str>, template: &str) -> Option<usize> {
self.find_stop(|s| {
matches!(s, WorkerRouteStop::TradeWith { npc_id: n, template: t, .. }
if n.as_deref() == npc_id && t == template)
})
}
pub fn insert_stop(&mut self, stop: WorkerRouteStop) -> (bool, usize) {
let existing = match &stop {
WorkerRouteStop::HarvestNode { node_id } => self.harvest_node_index(node_id),
WorkerRouteStop::DepositAt { container_id, .. } => {
self.deposit_container_index(container_id)
}
WorkerRouteStop::TradeWith { npc_id, template, .. } => {
self.trade_stop_index(npc_id.as_deref(), template)
}
_ => None,
};
if let Some(idx) = existing {
self.selected_stop_index = idx;
return (false, idx);
}
self.stops.push(stop);
self.selected_stop_index = self.stops.len() - 1;
(true, self.stops.len() - 1)
}
pub fn append_waypoint(&mut self, x: f32, y: f32, z: f32) {
self.insert_stop(WorkerRouteStop::Waypoint { x, y, z });
}
pub fn append_harvest_node(&mut self, node_id: &str) -> bool {
self.insert_stop(WorkerRouteStop::HarvestNode {
node_id: node_id.to_string(),
})
.0
}
pub fn append_deposit_at(&mut self, container_id: &str) -> bool {
self.insert_stop(WorkerRouteStop::DepositAt {
container_id: container_id.to_string(),
filter: None,
})
.0
}
pub fn append_deposit_at_filtered(&mut self, container_id: &str, filter_templates: Vec<String>) {
self.stops.push(WorkerRouteStop::DepositAt {
container_id: container_id.to_string(),
filter: Some(filter_templates),
});
self.selected_stop_index = self.stops.len() - 1;
}
pub fn append_rest_if_needed(&mut self) {
self.insert_stop(WorkerRouteStop::RestIfNeeded);
}
pub fn append_wait(&mut self, wait_ticks: u64) {
self.insert_stop(WorkerRouteStop::Wait { wait_ticks });
}
pub fn append_trade_with(&mut self, template: String, npc_id: Option<String>, sell_all: bool) -> bool {
self.insert_stop(WorkerRouteStop::TradeWith {
npc_id,
template,
sell_all,
})
.0
}
pub fn set_selected_trade_npc(&mut self, npc_id: String) -> bool {
let Some(stop) = self.stops.get_mut(self.selected_stop_index) else {
return false;
};
let WorkerRouteStop::TradeWith { npc_id: slot, template, .. } = stop else {
return false;
};
*slot = Some(npc_id.clone());
let template = template.clone();
let selected = self.selected_stop_index;
if let Some(other) = self
.trade_stop_index(Some(npc_id.as_str()), template.as_str())
.filter(|&i| i != selected)
{
self.stops.remove(selected);
self.selected_stop_index = if other > selected { other - 1 } else { other };
}
true
}
pub fn set_selected_withdraw_container(&mut self, container_id: String) -> bool {
let Some(stop) = self.stops.get_mut(self.selected_stop_index) else {
return false;
};
if let WorkerRouteStop::WithdrawFrom { container_id: slot, .. } = stop {
*slot = container_id;
return true;
}
false
}
pub fn retarget_withdraw_container(&mut self, container_id: String) -> bool {
let idx = self.editing_index.unwrap_or(self.selected_stop_index);
let Some(stop) = self.stops.get_mut(idx) else {
return false;
};
if let WorkerRouteStop::WithdrawFrom { container_id: slot, .. } = stop {
*slot = container_id;
return true;
}
false
}
pub fn retarget_deposit_container(&mut self, container_id: String) -> bool {
let idx = self.editing_index.unwrap_or(self.selected_stop_index);
let Some(stop) = self.stops.get_mut(idx) else {
return false;
};
if let WorkerRouteStop::DepositAt { container_id: slot, .. } = stop {
*slot = container_id;
return true;
}
false
}
pub fn open_add_menu(&mut self) {
self.editing_index = None;
self.sheet = RouteEditorSheet::AddMenu { index: 0 };
}
pub fn open_sheet(&mut self, sheet: RouteEditorSheet) {
self.sheet = sheet;
}
pub fn begin_edit_selected(&mut self) {
if self.selected_stop_index < self.stops.len() {
self.editing_index = Some(self.selected_stop_index);
}
}
pub fn sheet_back(&mut self) {
use RouteEditorSheet as S;
let editing = self.editing_index.is_some();
let next = match &self.sheet {
S::Stops => return,
S::AddMenu { .. } | S::BedPicker { .. } => S::Stops,
S::WaypointMapPick => {
if editing {
S::Stops
} else {
S::WaypointMenu { index: 0 }
}
}
S::WithdrawItems { .. } => S::WithdrawContainers { index: 0 },
S::DepositFilter { .. } => S::DepositContainers { index: 0 },
S::SellItem { .. } => S::SellNpcs { index: 0 },
S::WithdrawContainers { .. }
| S::DepositContainers { .. }
| S::SellNpcs { .. }
if editing =>
{
S::Stops
}
_ => {
if editing {
S::Stops
} else {
S::AddMenu { index: 0 }
}
}
};
if matches!(next, S::Stops) {
self.editing_index = None;
}
self.sheet = next;
}
pub fn confirm_stop(&mut self, stop: WorkerRouteStop) -> bool {
let result = if let Some(idx) = self.editing_index.take() {
if idx < self.stops.len() {
self.stops[idx] = stop;
self.selected_stop_index = idx;
}
true
} else {
self.insert_stop(stop).0
};
self.sheet = RouteEditorSheet::Stops;
result
}
pub fn withdraw_line_drafts(
contents: &[ItemStack],
existing: &[WorkerRouteWithdrawItem],
) -> Vec<WithdrawLineDraft> {
let mut lines: Vec<WithdrawLineDraft> = Vec::new();
for s in contents {
if s.template_id.is_empty() {
continue;
}
match lines.iter_mut().find(|l| l.template == s.template_id) {
Some(l) => l.available += s.quantity,
None => lines.push(WithdrawLineDraft {
template: s.template_id.clone(),
available: s.quantity,
mode: WithdrawLineMode::Off,
}),
}
}
for item in existing {
let mode = match item.qty {
None => WithdrawLineMode::All,
Some(q) => WithdrawLineMode::Qty(q),
};
match lines.iter_mut().find(|l| l.template == item.template) {
Some(l) => l.mode = mode,
None => lines.push(WithdrawLineDraft {
template: item.template.clone(),
available: 0,
mode,
}),
}
}
lines.sort_by(|a, b| a.template.cmp(&b.template));
lines
}
pub fn withdraw_items_from_lines(lines: &[WithdrawLineDraft]) -> Vec<WorkerRouteWithdrawItem> {
lines
.iter()
.filter_map(|l| match l.mode {
WithdrawLineMode::Off => None,
WithdrawLineMode::All => Some(WorkerRouteWithdrawItem {
template: l.template.clone(),
qty: None,
}),
WithdrawLineMode::Qty(q) => Some(WorkerRouteWithdrawItem {
template: l.template.clone(),
qty: Some(q),
}),
})
.collect()
}
fn job_id(&self) -> String {
format!(
"route_{}",
self.worker_instance_id
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect::<String>()
)
}
pub fn build_idle_job_yaml(&self) -> String {
let job_id = self.job_id();
[format!("job_id: {job_id}"), "mode: idle".into(), "steps: []".into()].join("\n")
}
pub fn build_job_yaml(&self) -> Result<String, String> {
if self.stops.is_empty() {
return Err("add at least one stop (waypoint, harvest node, or deposit)".into());
}
let job_id = self.job_id();
let mut lines = vec![
format!("job_id: {job_id}"),
"mode: job_loop".into(),
"route:".into(),
" kind: ordered".into(),
];
if let Some(lodging) = &self.lodging_container_id {
lines.push(format!(" lodging_container_id: {lodging}"));
}
lines.push(format!(
" carry_return_ratio: {:.2}",
self.carry_return_ratio
));
lines.push(" stops:".into());
for stop in &self.stops {
match stop {
WorkerRouteStop::Waypoint { x, y, z } => {
lines.push(format!(
" - {{ stop: waypoint, x: {:.1}, y: {:.1}, z: {:.1} }}",
x, y, z
));
}
WorkerRouteStop::HarvestNode { node_id } => {
lines.push(format!(" - {{ stop: harvest_node, node_id: {node_id} }}"));
}
WorkerRouteStop::DepositAt { container_id, filter } => {
let f = filter
.as_ref()
.filter(|f| !f.is_empty())
.map(|f| format!(", filter: [{}]", f.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")))
.unwrap_or_default();
lines.push(format!(
" - {{ stop: deposit_at, container_id: {container_id}{f} }}"
));
}
WorkerRouteStop::RestIfNeeded => {
lines.push(" - { stop: rest_if_needed }".into());
}
WorkerRouteStop::Wait { wait_ticks } => {
lines.push(format!(" - {{ stop: wait, wait_ticks: {wait_ticks} }}"));
}
WorkerRouteStop::TradeWith {
npc_id,
template,
sell_all,
} => {
let who = npc_id
.as_deref()
.map(|n| format!(", npc_id: {n}"))
.unwrap_or_default();
lines.push(format!(
" - {{ stop: trade_with, template: {template}{who}, sell_all: {sell_all} }}"
));
}
WorkerRouteStop::WithdrawFrom { container_id, items } => {
let mut block = format!(
" - stop: withdraw_from\n container_id: {container_id}\n items:"
);
for it in items {
let line = match it.qty {
None => format!("\n - {{ template: {}, all: true }}", it.template),
Some(q) => format!("\n - {{ template: {}, qty: {} }}", it.template, q),
};
block.push_str(&line);
}
lines.push(block);
}
WorkerRouteStop::CraftAt {
device,
blueprint,
qty,
} => {
let qty_str = qty
.map(|q| format!(", qty: {q}"))
.unwrap_or_default();
lines.push(format!(
" - {{ stop: craft_at, device: {device}, blueprint: {blueprint}{qty_str} }}"
));
}
}
}
lines.push("steps: []".into());
Ok(lines.join("\n"))
}
pub fn to_route_view(&self) -> flatland_protocol::WorkerRouteView {
use flatland_protocol::{
WorkerRouteKindView, WorkerRouteStopView, WorkerRouteView, WorkerWithdrawItemView,
};
WorkerRouteView {
kind: WorkerRouteKindView::Ordered,
lodging_container_id: self.lodging_container_id.clone(),
outbound_waypoints: Vec::new(),
harvest_nodes: Vec::new(),
carry_return_ratio: self.carry_return_ratio,
stops: self
.stops
.iter()
.map(|stop| match stop {
WorkerRouteStop::Waypoint { x, y, z } => WorkerRouteStopView::Waypoint {
x: *x,
y: *y,
z: *z,
},
WorkerRouteStop::HarvestNode { node_id } => WorkerRouteStopView::HarvestNode {
node_id: node_id.clone(),
},
WorkerRouteStop::DepositAt { container_id, filter } => {
WorkerRouteStopView::DepositAt {
container_id: container_id.clone(),
filter: filter.clone(),
}
}
WorkerRouteStop::TradeWith {
npc_id,
template,
sell_all,
} => WorkerRouteStopView::TradeWith {
npc_id: npc_id.clone(),
template: template.clone(),
sell_all: *sell_all,
},
WorkerRouteStop::WithdrawFrom { container_id, items } => {
WorkerRouteStopView::WithdrawFrom {
container_id: container_id.clone(),
items: items
.iter()
.map(|i| WorkerWithdrawItemView {
template: i.template.clone(),
qty: i.qty.unwrap_or(0),
all: i.qty.is_none(),
})
.collect(),
}
}
WorkerRouteStop::CraftAt {
device,
blueprint,
qty,
} => WorkerRouteStopView::CraftAt {
device: device.clone(),
blueprint: blueprint.clone(),
qty: *qty,
},
WorkerRouteStop::RestIfNeeded => WorkerRouteStopView::RestIfNeeded,
WorkerRouteStop::Wait { wait_ticks } => WorkerRouteStopView::Wait {
wait_ticks: *wait_ticks,
},
})
.collect(),
}
}
}
fn stop_view_to_stop(view: &WorkerRouteStopView) -> WorkerRouteStop {
match view {
WorkerRouteStopView::Waypoint { x, y, z } => WorkerRouteStop::Waypoint {
x: *x,
y: *y,
z: *z,
},
WorkerRouteStopView::HarvestNode { node_id } => WorkerRouteStop::HarvestNode {
node_id: node_id.clone(),
},
WorkerRouteStopView::DepositAt { container_id, filter } => WorkerRouteStop::DepositAt {
container_id: container_id.clone(),
filter: filter.clone(),
},
WorkerRouteStopView::TradeWith {
npc_id,
template,
sell_all,
} => WorkerRouteStop::TradeWith {
npc_id: npc_id.clone(),
template: template.clone(),
sell_all: *sell_all,
},
WorkerRouteStopView::WithdrawFrom { container_id, items } => WorkerRouteStop::WithdrawFrom {
container_id: container_id.clone(),
items: items
.iter()
.map(|i| WorkerRouteWithdrawItem {
template: i.template.clone(),
qty: if i.all { None } else { Some(i.qty) },
})
.collect(),
},
WorkerRouteStopView::CraftAt {
device,
blueprint,
qty,
} => WorkerRouteStop::CraftAt {
device: device.clone(),
blueprint: blueprint.clone(),
qty: *qty,
},
WorkerRouteStopView::RestIfNeeded => WorkerRouteStop::RestIfNeeded,
WorkerRouteStopView::Wait { wait_ticks } => WorkerRouteStop::Wait {
wait_ticks: *wait_ticks,
},
}
}
const HARVEST_NODE_PICK_M: f32 = 4.0;
const LODGING_PICK_M: f32 = 5.0;
const STORAGE_PICK_M: f32 = 5.0;
fn dist2d(x0: f32, y0: f32, x1: f32, y1: f32) -> f32 {
let dx = x0 - x1;
let dy = y0 - y1;
(dx * dx + dy * dy).sqrt()
}
pub fn pick_resource_node_at<'a>(
nodes: &'a [ResourceNodeView],
x: f32,
y: f32,
) -> Option<&'a ResourceNodeView> {
nodes
.iter()
.filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
.filter_map(|n| {
let d = dist2d(x, y, n.x, n.y);
if d <= HARVEST_NODE_PICK_M {
Some((d, n))
} else {
None
}
})
.min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
.map(|(_, n)| n)
}
pub fn owned_lodging_container_ids(
placed: &[PlacedContainerView],
character_id: Option<uuid::Uuid>,
) -> Vec<(String, String)> {
owned_lodging_container_ids_with_occupants(placed, character_id, &[])
}
pub fn owned_lodging_container_ids_with_occupants(
placed: &[PlacedContainerView],
character_id: Option<uuid::Uuid>,
hired: &[flatland_protocol::HiredWorkerView],
) -> Vec<(String, String)> {
let Some(cid) = character_id else {
return Vec::new();
};
let mut out: Vec<(String, String)> = placed
.iter()
.filter(|c| c.worker_lodging_capacity.unwrap_or(0) > 0)
.filter(|c| c.owner_character_id == Some(cid))
.map(|c| {
let who = lodging_occupants_for(hired, &c.id);
let name = if who.is_empty() {
format!("{} — vacant", c.display_name)
} else {
format!("{} — {}", c.display_name, who.join(", "))
};
(c.id.clone(), name)
})
.collect();
out.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
out
}
pub fn pick_lodging_container_at(
placed: &[PlacedContainerView],
character_id: Option<uuid::Uuid>,
x: f32,
y: f32,
) -> Option<String> {
let cid = character_id?;
placed
.iter()
.filter(|c| c.worker_lodging_capacity.unwrap_or(0) > 0)
.filter(|c| c.owner_character_id == Some(cid))
.filter_map(|c| {
let d = dist2d(x, y, c.x, c.y);
if d <= LODGING_PICK_M {
Some((d, c.id.clone()))
} else {
None
}
})
.min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
.map(|(_, id)| id)
}
pub fn pick_trade_npc_at(
npcs: &[NpcView],
x: f32,
y: f32,
) -> Option<(String, String)> {
const NPC_PICK_M: f32 = 5.0;
npcs.iter()
.filter(|n| n.can_trade)
.filter_map(|n| {
let d = dist2d(x, y, n.x, n.y);
if d <= NPC_PICK_M {
Some((d, n.id.clone(), n.label.clone()))
} else {
None
}
})
.min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
.map(|(_, id, label)| (id, label))
}
pub fn owned_storage_template_ids(
placed: &[PlacedContainerView],
character_id: Option<uuid::Uuid>,
) -> Vec<String> {
let Some(cid) = character_id else {
return Vec::new();
};
let mut out: Vec<String> = placed
.iter()
.filter(|c| c.owner_character_id == Some(cid))
.flat_map(|c| c.contents.iter().map(|s| s.template_id.clone()))
.filter(|t| !t.is_empty())
.collect();
out.sort();
out.dedup();
out
}
pub fn worker_craft_blueprint_ids(
blueprints: &[flatland_protocol::BlueprintView],
known_blueprint_ids: Option<&[String]>,
) -> Vec<String> {
let mut ids: Vec<String> = blueprints.iter().map(|b| b.id.clone()).collect();
if let Some(known) = known_blueprint_ids {
if !known.is_empty() {
ids.retain(|id| known.iter().any(|k| k == id));
}
}
ids
}
pub fn route_item_template_candidates(
placed: &[PlacedContainerView],
character_id: Option<uuid::Uuid>,
inventory: &std::collections::HashMap<String, u32>,
blueprints: &[flatland_protocol::BlueprintView],
resource_nodes: &[flatland_protocol::ResourceNodeView],
extra: &[String],
) -> Vec<String> {
let mut out = owned_storage_template_ids(placed, character_id);
for (template, qty) in inventory {
if *qty > 0 && !template.is_empty() {
out.push(template.clone());
}
}
for bp in blueprints {
if !bp.output.is_empty() {
out.push(bp.output.clone());
}
for input in &bp.inputs {
if !input.template_id.is_empty() {
out.push(input.template_id.clone());
}
}
for tool in &bp.required_tools {
if !tool.item.is_empty() {
out.push(tool.item.clone());
}
}
}
for node in resource_nodes {
if !node.item_template.is_empty() {
out.push(node.item_template.clone());
}
}
for t in extra {
if !t.is_empty() {
out.push(t.clone());
}
}
out.sort();
out.dedup();
out
}
pub fn pick_storage_container_at(
placed: &[PlacedContainerView],
character_id: Option<uuid::Uuid>,
x: f32,
y: f32,
) -> Option<String> {
let cid = character_id?;
placed
.iter()
.filter(|c| c.owner_character_id == Some(cid))
.filter(|c| c.capacity_volume.unwrap_or(0.0) > 0.0)
.filter_map(|c| {
let d = dist2d(x, y, c.x, c.y);
if d <= STORAGE_PICK_M {
Some((d, c.id.clone()))
} else {
None
}
})
.min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
.map(|(_, id)| id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn worker_craft_blueprint_ids_filters_to_known_recipes() {
use flatland_protocol::{BlueprintIngredientView, BlueprintView};
fn bp(id: &str) -> BlueprintView {
BlueprintView {
id: id.into(),
label: id.into(),
output: "x".into(),
output_qty: 1,
craft_ticks: 1,
inputs: vec![BlueprintIngredientView {
template_id: "oak_log".into(),
quantity: 1,
consumed: true,
}],
station: Some("hand".into()),
category: None,
required_tools: vec![],
skill: None,
failure_chance: 0.0,
worker_train_copper: 0,
}
}
let all = vec![
bp("oak_to_lumber"),
bp("vegetable_soup"),
bp("craft_simple_camp_bed"),
bp("craft_wooden_chest_small"),
bp("iron_ingot"),
];
let known = vec![
"oak_to_lumber".into(),
"craft_simple_camp_bed".into(),
"craft_wooden_chest_small".into(),
];
let filtered = worker_craft_blueprint_ids(&all, Some(&known));
assert_eq!(
filtered,
vec![
"oak_to_lumber",
"craft_simple_camp_bed",
"craft_wooden_chest_small"
]
);
assert_eq!(
worker_craft_blueprint_ids(&all, Some(&[])).len(),
all.len()
);
assert_eq!(worker_craft_blueprint_ids(&all, None).len(), all.len());
}
#[test]
fn route_item_candidates_include_craft_outputs_not_in_storage() {
use flatland_protocol::{
BlueprintIngredientView, BlueprintView, PlacedContainerView, ResourceNodeView,
ResourceNodeState,
};
use std::collections::HashMap;
use uuid::Uuid;
let cid = Uuid::from_u128(0x1111_2222_3333_4444_5555_6666_7777_8888);
let placed = vec![PlacedContainerView {
id: "chest-1".into(),
template_id: "wooden_chest_small".into(),
display_name: "Chest".into(),
x: 0.0,
y: 0.0,
z: 0.0,
locked: false,
accessible: true,
owner_character_id: Some(cid),
contents: vec![],
lock_id: None,
capacity_volume: Some(20.0),
item_instance_id: None,
tile_id: None,
worker_lodging_capacity: None,
}];
let blueprints = vec![BlueprintView {
id: "smelt_iron".into(),
label: "Smelt Iron".into(),
output: "iron_ingot".into(),
output_qty: 1,
craft_ticks: 30,
inputs: vec![BlueprintIngredientView {
template_id: "iron_ore".into(),
quantity: 1,
consumed: true,
}],
station: Some("hand".into()),
category: None,
required_tools: vec![],
skill: None,
failure_chance: 0.0,
worker_train_copper: 0,
}];
let nodes = vec![ResourceNodeView {
id: "ore-1".into(),
label: "Iron Ore".into(),
x: 1.0,
y: 1.0,
z: 0.0,
item_template: "iron_ore".into(),
state: ResourceNodeState::Available,
blocking: true,
blocking_radius_m: 0.8,
tile_id: None,
sprite_mode: None,
presentation_state: None,
}];
let ids = route_item_template_candidates(
&placed,
Some(cid),
&HashMap::new(),
&blueprints,
&nodes,
&[],
);
assert!(
ids.contains(&"iron_ingot".to_string()),
"craft output should be selectable before any exists in storage: {ids:?}"
);
assert!(ids.contains(&"iron_ore".to_string()));
}
#[test]
fn build_ordered_job_yaml_includes_stops() {
let mut ed = WorkerRouteEditorState::new(
"worker-worker_laborer-1".into(),
"Laborer".into(),
Some("chest-bed".into()),
);
ed.append_waypoint(10.0, 20.0, 0.0);
ed.append_harvest_node("oak-n1");
ed.append_deposit_at("chest-storage-a");
ed.append_rest_if_needed();
let yaml = ed.build_job_yaml().expect("yaml");
assert!(yaml.contains("kind: ordered"));
assert!(yaml.contains("lodging_container_id: chest-bed"));
assert!(yaml.contains("stop: waypoint"));
assert!(yaml.contains("oak-n1"));
assert!(yaml.contains("deposit_at"));
assert!(yaml.contains("chest-storage-a"));
assert!(yaml.contains("rest_if_needed"));
}
#[test]
fn requires_at_least_one_stop() {
let ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
assert!(ed.build_job_yaml().is_err());
}
#[test]
fn reorder_stops() {
let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
ed.append_harvest_node("oak-a");
ed.append_harvest_node("oak-b");
ed.append_waypoint(5.0, 6.0, 0.0);
ed.select_stop(1);
ed.move_selected_up();
assert!(
matches!(&ed.stops[0], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
);
ed.move_selected_down();
assert!(
matches!(&ed.stops[1], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
);
}
#[test]
fn duplicate_harvest_node_selects_existing_instead() {
let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
assert!(ed.append_harvest_node("oak-a"));
ed.append_waypoint(1.0, 2.0, 0.0);
assert!(!ed.append_harvest_node("oak-a"));
assert_eq!(ed.stops.len(), 2);
assert_eq!(ed.selected_stop_index, 0);
}
#[test]
fn duplicate_deposit_container_selects_existing_instead() {
let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
assert!(ed.append_deposit_at("chest-1"));
ed.append_harvest_node("oak-a");
assert!(!ed.append_deposit_at("chest-1"));
assert_eq!(ed.stops.len(), 2);
assert_eq!(ed.selected_stop_index, 0);
}
#[test]
fn duplicate_trade_stop_selects_existing_instead() {
let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
assert!(ed.append_trade_with("oak_log".into(), Some("ada".into()), true));
assert!(!ed.append_trade_with("oak_log".into(), Some("ada".into()), true));
assert!(ed.append_trade_with("lumber".into(), Some("ada".into()), true));
assert_eq!(ed.stops.len(), 2);
}
#[test]
fn build_idle_job_yaml_parks_worker() {
let ed = WorkerRouteEditorState::new("w1".into(), "L".into(), Some("bed-1".into()));
let yaml = ed.build_idle_job_yaml();
assert!(yaml.contains("mode: idle"));
assert!(yaml.contains("steps: []"));
assert!(!yaml.contains("route:"));
}
#[test]
fn remove_selected_stop_adjusts_index() {
let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
ed.append_waypoint(1.0, 2.0, 0.0);
ed.append_harvest_node("oak-a");
ed.append_deposit_at("chest-1");
ed.select_stop(2);
ed.remove_selected_stop();
assert_eq!(ed.stops.len(), 2);
assert_eq!(ed.selected_stop_index, 1);
}
#[test]
fn build_job_yaml_includes_trade_with_stop() {
let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
ed.append_trade_with("oak_log".into(), None, true);
ed.append_trade_with("lumber".into(), Some("ada_broker".into()), false);
let yaml = ed.build_job_yaml().expect("yaml");
assert!(yaml.contains("stop: trade_with, template: oak_log, sell_all: true"));
assert!(yaml.contains("npc_id: ada_broker"));
assert!(yaml.contains("sell_all: false"));
}
#[test]
fn set_selected_trade_npc_updates_stop() {
let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
ed.append_trade_with("oak_log".into(), None, true);
assert!(ed.set_selected_trade_npc("ada_broker".into()));
assert!(matches!(&ed.stops[0], WorkerRouteStop::TradeWith { npc_id, .. } if npc_id.as_deref() == Some("ada_broker")));
}
#[test]
fn build_job_yaml_deposit_filter_round_trips() {
let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), Some("bed-1".into()));
ed.append_deposit_at_filtered("chest-out", vec!["lumber".into()]);
let yaml = ed.build_job_yaml().expect("yaml");
assert!(yaml.contains("stop: deposit_at, container_id: chest-out, filter: [lumber]"));
}
#[test]
fn build_job_yaml_includes_withdraw_and_craft_stops() {
let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
ed.stops.push(WorkerRouteStop::WithdrawFrom {
container_id: "chest-src".into(),
items: vec![WorkerRouteWithdrawItem { template: "oak_log".into(), qty: None }],
});
ed.stops.push(WorkerRouteStop::WithdrawFrom {
container_id: "chest-src-2".into(),
items: vec![WorkerRouteWithdrawItem { template: "iron_ore".into(), qty: Some(10) }],
});
ed.stops.push(WorkerRouteStop::CraftAt {
device: "hand".into(),
blueprint: "oak_to_lumber".into(),
qty: None,
});
ed.append_deposit_at("chest-out");
let yaml = ed.build_job_yaml().expect("yaml");
assert!(yaml.contains("stop: withdraw_from"));
assert!(yaml.contains("container_id: chest-src"));
assert!(yaml.contains("template: oak_log, all: true"));
assert!(yaml.contains("template: iron_ore, qty: 10"));
assert!(yaml.contains("stop: craft_at, device: hand, blueprint: oak_to_lumber"));
assert!(yaml.contains("stop: deposit_at"));
}
#[test]
fn withdraw_summary_shows_all_vs_qty() {
let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
ed.stops.push(WorkerRouteStop::WithdrawFrom {
container_id: "chest-src".into(),
items: vec![WorkerRouteWithdrawItem { template: "oak_log".into(), qty: None }],
});
assert!(ed.stops[0].summary().contains("withdraw all oak_log"));
}
#[test]
fn withdraw_view_round_trips_all_flag() {
let view = WorkerRouteStopView::WithdrawFrom {
container_id: "chest-1".into(),
items: vec![
flatland_protocol::WorkerWithdrawItemView {
template: "oak_log".into(),
qty: 0,
all: true,
},
flatland_protocol::WorkerWithdrawItemView {
template: "iron_ore".into(),
qty: 5,
all: false,
},
],
};
let stop = stop_view_to_stop(&view);
let WorkerRouteStop::WithdrawFrom { items, .. } = stop else {
panic!("expected withdraw stop");
};
assert_eq!(items[0].qty, None);
assert_eq!(items[1].qty, Some(5));
}
#[test]
fn legacy_harvest_loop_route_converts_to_ordered_stops() {
let route = WorkerRouteView {
kind: WorkerRouteKindView::HarvestLoop,
lodging_container_id: Some("bed-1".into()),
outbound_waypoints: vec![flatland_protocol::WorkerRouteWaypointView {
x: 1.0,
y: 2.0,
z: 0.0,
}],
harvest_nodes: vec!["oak-1".into()],
carry_return_ratio: 0.9,
stops: Vec::new(),
};
let ed = WorkerRouteEditorState::from_saved_route(
"w1".into(),
"L".into(),
&route,
None,
);
assert_eq!(ed.stops.len(), 4);
assert!(matches!(&ed.stops[2], WorkerRouteStop::DepositAt { container_id, .. } if container_id == "bed-1"));
assert!(matches!(&ed.stops[3], WorkerRouteStop::RestIfNeeded));
}
#[test]
fn retarget_withdraw_container_updates_editing_stop() {
let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
ed.stops.push(WorkerRouteStop::WithdrawFrom {
container_id: "chest-old".into(),
items: vec![WorkerRouteWithdrawItem {
template: "iron_ore".into(),
qty: None,
}],
});
ed.selected_stop_index = 0;
ed.editing_index = Some(0);
assert!(ed.retarget_withdraw_container("chest-new".into()));
assert!(matches!(
&ed.stops[0],
WorkerRouteStop::WithdrawFrom { container_id, .. } if container_id == "chest-new"
));
}
#[test]
fn summary_resolved_uses_friendly_labels() {
let stop = WorkerRouteStop::WithdrawFrom {
container_id: "uuid-iron".into(),
items: vec![WorkerRouteWithdrawItem {
template: "iron_ore".into(),
qty: None,
}],
};
let summary = stop.summary_resolved(
|_| "Iron Ore Container".into(),
|_| "Ada".into(),
|_| "Oak Tree".into(),
);
assert_eq!(summary, "withdraw all iron_ore from Iron Ore Container");
assert!(!summary.contains("uuid"));
}
#[test]
fn sheet_back_walks_up_hierarchy() {
use RouteEditorSheet as S;
let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
assert_eq!(ed.sheet, S::Stops);
ed.open_add_menu();
assert_eq!(ed.sheet, S::AddMenu { index: 0 });
ed.open_sheet(S::WithdrawContainers { index: 0 });
ed.open_sheet(S::WithdrawItems {
container_id: "c1".into(),
lines: vec![],
index: 0,
});
ed.sheet_back();
assert_eq!(ed.sheet, S::WithdrawContainers { index: 0 });
ed.sheet_back();
assert_eq!(ed.sheet, S::AddMenu { index: 0 });
ed.sheet_back();
assert_eq!(ed.sheet, S::Stops);
ed.sheet_back();
assert_eq!(ed.sheet, S::Stops);
}
#[test]
fn sheet_back_while_editing_returns_to_stops() {
use RouteEditorSheet as S;
let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
ed.append_harvest_node("oak-a");
ed.begin_edit_selected();
ed.open_sheet(S::HarvestPicker { index: 0 });
ed.sheet_back();
assert_eq!(ed.sheet, S::Stops);
assert_eq!(ed.editing_index, None);
}
#[test]
fn sheet_back_while_editing_withdraw_keeps_edit_on_container_picker() {
use RouteEditorSheet as S;
let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
ed.stops.push(WorkerRouteStop::WithdrawFrom {
container_id: "chest-a".into(),
items: vec![WorkerRouteWithdrawItem {
template: "oak_log".into(),
qty: None,
}],
});
ed.begin_edit_selected();
ed.open_sheet(S::WithdrawItems {
container_id: "chest-a".into(),
lines: vec![],
index: 0,
});
ed.sheet_back();
assert!(matches!(ed.sheet, S::WithdrawContainers { .. }));
assert_eq!(ed.editing_index, Some(0), "still editing after back to picker");
ed.sheet_back();
assert_eq!(ed.sheet, S::Stops);
assert_eq!(ed.editing_index, None);
}
#[test]
fn confirm_stop_replaces_withdraw_container_when_editing() {
let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
ed.stops.push(WorkerRouteStop::WithdrawFrom {
container_id: "chest-old".into(),
items: vec![WorkerRouteWithdrawItem {
template: "oak_log".into(),
qty: None,
}],
});
ed.stops.push(WorkerRouteStop::RestIfNeeded);
ed.select_stop(0);
ed.begin_edit_selected();
assert!(ed.confirm_stop(WorkerRouteStop::WithdrawFrom {
container_id: "chest-new".into(),
items: vec![WorkerRouteWithdrawItem {
template: "oak_log".into(),
qty: None,
}],
}));
assert_eq!(ed.stops.len(), 2);
assert!(matches!(
&ed.stops[0],
WorkerRouteStop::WithdrawFrom { container_id, .. } if container_id == "chest-new"
));
}
#[test]
fn confirm_stop_replaces_when_editing() {
let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
ed.append_harvest_node("oak-a");
ed.append_waypoint(1.0, 1.0, 0.0);
ed.select_stop(0);
ed.begin_edit_selected();
assert!(ed.confirm_stop(WorkerRouteStop::HarvestNode {
node_id: "oak-b".into()
}));
assert_eq!(ed.stops.len(), 2, "edit replaces in place, no append");
assert!(
matches!(&ed.stops[0], WorkerRouteStop::HarvestNode { node_id } if node_id == "oak-b")
);
assert_eq!(ed.sheet, RouteEditorSheet::Stops);
assert_eq!(ed.editing_index, None);
}
#[test]
fn confirm_stop_dedupes_on_append() {
let mut ed = WorkerRouteEditorState::new("w1".into(), "L".into(), None);
ed.append_harvest_node("oak-a");
assert!(!ed.confirm_stop(WorkerRouteStop::HarvestNode {
node_id: "oak-a".into()
}));
assert_eq!(ed.stops.len(), 1);
assert_eq!(ed.selected_stop_index, 0);
}
#[test]
fn withdraw_line_cycle_and_collect() {
let contents = vec![
ItemStack {
template_id: "oak_log".into(),
quantity: 12,
..Default::default()
},
ItemStack {
template_id: "lumber".into(),
quantity: 4,
..Default::default()
},
];
let mut lines =
WorkerRouteEditorState::withdraw_line_drafts(&contents, &[]);
assert_eq!(lines.len(), 2);
lines[1].cycle();
assert_eq!(lines[1].mode, WithdrawLineMode::All);
lines[0].cycle();
lines[0].cycle();
assert!(matches!(lines[0].mode, WithdrawLineMode::Qty(_)));
lines[0].adjust_qty(5);
let items = WorkerRouteEditorState::withdraw_items_from_lines(&lines);
assert_eq!(items.len(), 2);
assert_eq!(items[0].template, "lumber");
assert_eq!(items[0].qty, Some(4));
assert_eq!(items[1].qty, None);
}
#[test]
fn withdraw_drafts_prefill_existing_and_keep_missing() {
let contents = vec![ItemStack {
template_id: "oak_log".into(),
quantity: 3,
..Default::default()
}];
let existing = vec![
WorkerRouteWithdrawItem { template: "oak_log".into(), qty: None },
WorkerRouteWithdrawItem { template: "iron_ore".into(), qty: Some(5) },
];
let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
assert_eq!(lines.len(), 2);
let ore = lines.iter().find(|l| l.template == "iron_ore").expect("ore line");
assert_eq!(ore.available, 0, "missing template kept with 0 available");
assert_eq!(ore.mode, WithdrawLineMode::Qty(5));
let oak = lines.iter().find(|l| l.template == "oak_log").expect("oak line");
assert_eq!(oak.mode, WithdrawLineMode::All);
}
}