use std::collections::BTreeSet;
use flatland_protocol::{
ItemCatalogEntryView, 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,
},
ListOnMarket {
template: String,
list_all: bool,
hall_id: Option<String>,
},
WithdrawFrom {
container_id: String,
items: Vec<WorkerRouteWithdrawItem>,
},
CraftAt {
device: String,
blueprint: String,
qty: Option<u32>,
},
CultivatePlot {
plot_id: uuid::Uuid,
},
PlantPlot {
plot_id: uuid::Uuid,
seed_template: String,
},
HarvestPlot {
plot_id: uuid::Uuid,
},
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(),
|id| format!("plot {}", short_plot_id(id)),
)
}
pub fn summary_resolved(
&self,
container_label: impl Fn(&str) -> String,
npc_label: impl Fn(&str) -> String,
node_label: impl Fn(&str) -> String,
plot_label: impl Fn(&uuid::Uuid) -> 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::ListOnMarket {
template, hall_id, ..
} => {
let hall = hall_id.as_deref().unwrap_or("market hall");
format!("list {template} on {hall} (NPC price)")
}
Self::WithdrawFrom {
container_id,
items,
} => {
let what = items
.iter()
.map(|i| match i.qty {
None => format!("all {}", i.template),
Some(q) => format!("up to {q} {}", i.template),
})
.collect::<Vec<_>>()
.join(" + ");
format!("withdraw {what} from {}", container_label(container_id))
}
Self::CraftAt { blueprint, .. } => format!("craft {blueprint}"),
Self::CultivatePlot { plot_id } => {
format!("cultivate {}", plot_label(plot_id))
}
Self::PlantPlot {
plot_id,
seed_template,
} => format!("plant {seed_template} on {}", plot_label(plot_id)),
Self::HarvestPlot { plot_id } => {
format!("harvest {}", plot_label(plot_id))
}
Self::RestIfNeeded => "rest at lodging (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::ListOnMarket { .. } => "market-list",
Self::WithdrawFrom { .. } => "withdraw",
Self::CraftAt { .. } => "craft",
Self::CultivatePlot { .. } => "cultivate",
Self::PlantPlot { .. } => "plant",
Self::HarvestPlot { .. } => "harvest-plot",
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()
}
fn short_plot_id(plot_id: &uuid::Uuid) -> String {
let s = plot_id.to_string();
s.get(..8).unwrap_or(s.as_str()).to_string()
}
pub fn list_filter_row_matches(filter: &str, dist_m: Option<f32>, fields: &[&str]) -> bool {
let tokens: Vec<&str> = filter
.split_whitespace()
.filter(|t| !t.is_empty())
.collect();
if tokens.is_empty() {
return true;
}
let hay: Vec<String> = fields.iter().map(|f| f.to_ascii_lowercase()).collect();
for tok in tokens {
let t = tok.to_ascii_lowercase();
if let Some(rest) = t.strip_suffix('m') {
if let Ok(max) = rest.parse::<f32>() {
if let Some(d) = dist_m {
if d > max {
return false;
}
continue;
}
}
}
if !hay.iter().any(|h| h.contains(&t)) {
return false;
}
}
true
}
#[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,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FarmPlotAction {
Cultivate,
Plant,
Harvest,
}
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,
picked: BTreeSet<String>,
nodes: Vec<NodeCandidate>,
},
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,
picked: BTreeSet<String>,
},
MarketListItem {
hall_id: Option<String>,
templates: Vec<String>,
index: usize,
list_all: bool,
picked: BTreeSet<String>,
},
CraftBlueprint { index: usize },
WaitEntry { ticks: u64 },
BedPicker { index: usize },
FarmPlotPicker {
index: usize,
action: FarmPlotAction,
},
FarmPlantSeed {
plot_id: uuid::Uuid,
seeds: Vec<String>,
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",
"List on market (NPC price)",
"Craft (at hand)",
"Rest if needed",
"Wait",
"Cultivate plot",
"Plant plot",
"Harvest plot",
];
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> {
owned_container_candidates_with_occupants_and_buildings(
placed,
&[],
character_id,
px,
py,
hired,
None,
)
}
pub fn owned_container_candidates_with_occupants_and_buildings(
placed: &[PlacedContainerView],
buildings: &[flatland_protocol::BuildingView],
character_id: Option<uuid::Uuid>,
px: f32,
py: f32,
hired: &[flatland_protocol::HiredWorkerView],
observer_inside: Option<&str>,
) -> 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}");
}
let cross_space = !container_in_observer_space(c, observer_inside);
let building = c
.building_id
.as_ref()
.and_then(|bid| buildings.iter().find(|b| &b.id == bid));
let (rx, ry) = if cross_space {
building
.map(|b| (b.x + b.width_m * 0.5, b.y + b.depth_m * 0.5))
.unwrap_or((c.x, c.y))
} else {
(c.x, c.y)
};
let name = if cross_space {
match building {
Some(b) if !b.label.is_empty() => {
format!("{} ({})", c.display_name, b.label)
}
Some(b) => format!("{} ({})", c.display_name, b.id),
None => c.display_name.clone(),
}
} else {
c.display_name.clone()
};
ContainerCandidate {
id: c.id.clone(),
name,
is_lodging,
summary,
dist: dist2d(px, py, rx, ry),
}
})
.collect();
for b in buildings {
if !b.tags.iter().any(|t| t.eq_ignore_ascii_case("storage")) {
continue;
}
let name = if b.label.is_empty() {
format!("Town storage ({})", b.id)
} else {
b.label.clone()
};
out.push(ContainerCandidate {
id: b.id.clone(),
name,
is_lodging: false,
summary: "(town vault)".into(),
dist: dist2d(px, py, b.x, b.y),
});
}
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],
anchor_x: f32,
anchor_y: f32,
) -> Vec<NodeCandidate> {
let mut out: Vec<NodeCandidate> = nodes
.iter()
.filter(|n| !n.harvest_off)
.filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
.map(|n| NodeCandidate {
id: n.id.clone(),
label: crate::resource_node_route_label(n),
template: n.item_template.clone(),
dist: dist2d(anchor_x, anchor_y, 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.label.cmp(&b.label))
.then_with(|| a.id.cmp(&b.id))
});
out
}
pub fn node_candidates_stable(nodes: &[ResourceNodeView]) -> Vec<NodeCandidate> {
let mut out: Vec<NodeCandidate> = nodes
.iter()
.filter(|n| !n.harvest_off)
.filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
.map(|n| NodeCandidate {
id: n.id.clone(),
label: crate::resource_node_route_label(n),
template: n.item_template.clone(),
dist: f32::NAN,
})
.collect();
out.sort_by(|a, b| a.label.cmp(&b.label).then_with(|| a.id.cmp(&b.id)));
out
}
pub fn route_editor_lodging_anchor(
lodging_container_id: Option<&str>,
placed: &[PlacedContainerView],
) -> Option<(f32, f32)> {
let id = lodging_container_id?;
placed.iter().find(|c| c.id == id).map(|c| (c.x, c.y))
}
pub const ROUTE_PICKER_DONE_ROW: usize = 0;
pub const SELL_ITEM_TOGGLE_ROW: usize = 1;
pub fn harvest_picker_row_count(nodes_len: usize) -> usize {
nodes_len + 1
}
pub fn sell_item_picker_row_count(templates_len: usize) -> usize {
templates_len + 2
}
pub fn harvest_picker_row_matches(nodes: &[NodeCandidate], row: usize, filter: &str) -> bool {
if row == ROUTE_PICKER_DONE_ROW {
return true;
}
let slot = row - 1;
nodes.get(slot).is_some_and(|n| {
let dist = n.dist.is_finite().then_some(n.dist);
list_filter_row_matches(filter, dist, &[&n.label, &n.template, &n.id])
})
}
#[derive(Debug, Clone, PartialEq)]
pub struct TradeNpcCandidate {
pub id: String,
pub label: String,
pub dist: f32,
pub buys_route_item: bool,
}
pub fn merchant_buys_any_route_item(npc: &NpcView, route_templates: &[String]) -> bool {
if route_templates.is_empty() {
return false;
}
route_templates
.iter()
.any(|t| npc.buy_templates.iter().any(|b| b == t))
}
pub fn any_trade_npc_buys_route_item(npcs: &[NpcView], route_templates: &[String]) -> bool {
npcs.iter()
.filter(|n| n.can_trade)
.any(|n| merchant_buys_any_route_item(n, route_templates))
}
pub fn sell_merchant_empty_reason(
npc_id: Option<&str>,
npcs: &[NpcView],
route_templates: &[String],
) -> String {
let items = if route_templates.is_empty() {
"your route items".to_string()
} else {
route_templates.join("/")
};
match npc_id {
Some(id) => {
let Some(npc) = npcs.iter().find(|n| n.id == id) else {
return format!("Route: merchant {id} not found");
};
let name = if npc.label.is_empty() {
npc.id.as_str()
} else {
npc.label.as_str()
};
if npc.buy_templates.is_empty() {
format!("Route: {name} has an empty buy list — they don't buy any items")
} else {
format!("Route: {name} doesn't buy any of your route items ({items})")
}
}
None => {
if !any_trade_npc_buys_route_item(npcs, route_templates) {
format!(
"Route: no trade NPC buys any of your route items ({items})"
)
} else {
format!("Route: no sellable item templates for nearest buyer ({items})")
}
}
}
}
pub fn trade_npc_candidates(
npcs: &[NpcView],
px: f32,
py: f32,
route_templates: &[String],
) -> 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),
buys_route_item: merchant_buys_any_route_item(n, route_templates),
})
.collect();
out.sort_by(|a, b| {
b.buys_route_item
.cmp(&a.buys_route_item)
.then_with(|| {
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,
pub sheet_filter: String,
pub sheet_filter_focused: 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,
sheet_filter: String::new(),
sheet_filter_focused: 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,
sheet_filter: String::new(),
sheet_filter_focused: 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,
sheet_filter: String::new(),
sheet_filter_focused: 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_filter.clear();
self.sheet_filter_focused = false;
self.sheet = sheet;
}
pub fn confirm_harvest_picks(&mut self, node_ids: &[String]) -> usize {
if node_ids.is_empty() {
return 0;
}
let mut added = 0usize;
if let Some(idx) = self.editing_index.take() {
if let Some(first) = node_ids.first() {
if idx < self.stops.len() {
self.stops[idx] = WorkerRouteStop::HarvestNode {
node_id: first.clone(),
};
self.selected_stop_index = idx;
added = 1;
}
for id in node_ids.iter().skip(1) {
if self
.insert_stop(WorkerRouteStop::HarvestNode {
node_id: id.clone(),
})
.0
{
added += 1;
}
}
}
} else {
for id in node_ids {
if self
.insert_stop(WorkerRouteStop::HarvestNode {
node_id: id.clone(),
})
.0
{
added += 1;
}
}
}
self.sheet = RouteEditorSheet::Stops;
added
}
pub fn confirm_trade_picks(
&mut self,
npc_id: Option<String>,
templates: &[String],
sell_all: bool,
) -> usize {
if templates.is_empty() {
return 0;
}
let mut added = 0usize;
if let Some(idx) = self.editing_index.take() {
if let Some(first) = templates.first() {
if idx < self.stops.len() {
self.stops[idx] = WorkerRouteStop::TradeWith {
npc_id: npc_id.clone(),
template: first.clone(),
sell_all,
};
self.selected_stop_index = idx;
added = 1;
}
for template in templates.iter().skip(1) {
if self
.insert_stop(WorkerRouteStop::TradeWith {
npc_id: npc_id.clone(),
template: template.clone(),
sell_all,
})
.0
{
added += 1;
}
}
}
} else {
for template in templates {
if self
.insert_stop(WorkerRouteStop::TradeWith {
npc_id: npc_id.clone(),
template: template.clone(),
sell_all,
})
.0
{
added += 1;
}
}
}
self.sheet = RouteEditorSheet::Stops;
added
}
pub fn confirm_market_list_picks(
&mut self,
hall_id: Option<String>,
templates: &[String],
list_all: bool,
) -> usize {
if templates.is_empty() {
return 0;
}
let mut added = 0usize;
if let Some(idx) = self.editing_index.take() {
if let Some(first) = templates.first() {
if idx < self.stops.len() {
self.stops[idx] = WorkerRouteStop::ListOnMarket {
template: first.clone(),
list_all,
hall_id: hall_id.clone(),
};
self.selected_stop_index = idx;
added = 1;
}
for template in templates.iter().skip(1) {
if self
.insert_stop(WorkerRouteStop::ListOnMarket {
template: template.clone(),
list_all,
hall_id: hall_id.clone(),
})
.0
{
added += 1;
}
}
}
} else {
for template in templates {
if self
.insert_stop(WorkerRouteStop::ListOnMarket {
template: template.clone(),
list_all,
hall_id: hall_id.clone(),
})
.0
{
added += 1;
}
}
}
self.sheet = RouteEditorSheet::Stops;
added
}
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::FarmPlotPicker { .. } => S::Stops,
S::FarmPlantSeed { .. } => S::FarmPlotPicker {
index: 0,
action: FarmPlotAction::Plant,
},
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::ListOnMarket {
template,
list_all,
hall_id,
} => {
let hall = hall_id
.as_deref()
.map(|h| format!(", hall_id: {h}"))
.unwrap_or_default();
lines.push(format!(
" - {{ stop: list_on_market, template: {template}{hall}, list_all: {list_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} }}"
));
}
WorkerRouteStop::CultivatePlot { plot_id } => {
lines.push(format!(
" - {{ stop: cultivate_plot, plot_id: \"{plot_id}\" }}"
));
}
WorkerRouteStop::PlantPlot {
plot_id,
seed_template,
} => {
lines.push(format!(
" - {{ stop: plant_plot, plot_id: \"{plot_id}\", seed_template: {seed_template} }}"
));
}
WorkerRouteStop::HarvestPlot { plot_id } => {
lines.push(format!(
" - {{ stop: harvest_plot, plot_id: \"{plot_id}\" }}"
));
}
}
}
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::ListOnMarket {
template,
list_all,
hall_id,
} => WorkerRouteStopView::ListOnMarket {
template: template.clone(),
list_all: *list_all,
hall_id: hall_id.clone(),
},
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::CultivatePlot { plot_id } => {
WorkerRouteStopView::CultivatePlot { plot_id: *plot_id }
}
WorkerRouteStop::PlantPlot {
plot_id,
seed_template,
} => WorkerRouteStopView::PlantPlot {
plot_id: *plot_id,
seed_template: seed_template.clone(),
},
WorkerRouteStop::HarvestPlot { plot_id } => {
WorkerRouteStopView::HarvestPlot { plot_id: *plot_id }
}
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::ListOnMarket {
template,
list_all,
hall_id,
} => WorkerRouteStop::ListOnMarket {
template: template.clone(),
list_all: *list_all,
hall_id: hall_id.clone(),
},
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::CultivatePlot { plot_id } => {
WorkerRouteStop::CultivatePlot { plot_id: *plot_id }
}
WorkerRouteStopView::PlantPlot {
plot_id,
seed_template,
} => WorkerRouteStop::PlantPlot {
plot_id: *plot_id,
seed_template: seed_template.clone(),
},
WorkerRouteStopView::HarvestPlot { plot_id } => {
WorkerRouteStop::HarvestPlot { plot_id: *plot_id }
}
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.harvest_off)
.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,
observer_inside: Option<&str>,
) -> Option<String> {
let cid = character_id?;
placed
.iter()
.filter(|c| container_in_observer_space(c, observer_inside))
.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],
catalog: Option<&std::collections::HashMap<String, ItemCatalogEntryView>>,
) -> 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 {
for t in &node.harvest_drop_templates {
if !t.is_empty() {
out.push(t.clone());
}
}
}
for t in extra {
if !t.is_empty() {
out.push(t.clone());
}
}
out.sort();
out.dedup();
if let Some(catalog) = catalog {
out.retain(|id| catalog.get(id).is_none_or(|e| e.is_depositable_stack()));
}
out
}
pub fn sellable_route_item_template_candidates(
templates: &[String],
npcs: &[flatland_protocol::NpcView],
npc_id: Option<&str>,
) -> Vec<String> {
let accepted = npcs
.iter()
.filter(|npc| npc_id.is_none_or(|id| npc.id == id))
.filter(|npc| npc_id.is_some() || npc.can_trade)
.flat_map(|npc| npc.buy_templates.iter().map(String::as_str))
.collect::<std::collections::HashSet<_>>();
let mut out: Vec<String> = templates
.iter()
.filter(|template| accepted.contains(template.as_str()))
.cloned()
.collect();
out.sort();
out.dedup();
out
}
pub fn pick_storage_container_at(
placed: &[PlacedContainerView],
character_id: Option<uuid::Uuid>,
x: f32,
y: f32,
observer_inside: Option<&str>,
) -> Option<String> {
let cid = character_id?;
placed
.iter()
.filter(|c| container_in_observer_space(c, observer_inside))
.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)
}
fn container_in_observer_space(c: &PlacedContainerView, observer_inside: Option<&str>) -> bool {
match (observer_inside, c.building_id.as_deref()) {
(None, None) => true,
(Some(a), Some(b)) => a == b,
_ => false,
}
}
#[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,
display_name: "Oak Log".into(),
}],
station: Some("hand".into()),
category: None,
required_tools: vec![],
skill: None,
failure_chance: 0.0,
worker_train_copper: 0,
output_display_name: "X".into(),
craft_tier: 1,
}
}
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, ResourceNodeState,
ResourceNodeView,
};
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,
blocking: false,
blocking_radius_m: 0.0,
building_id: 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,
display_name: "Iron Ore".into(),
}],
station: Some("hand".into()),
category: None,
required_tools: vec![],
skill: None,
failure_chance: 0.0,
worker_train_copper: 0,
output_display_name: "Iron Ingot".into(),
craft_tier: 1,
}];
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,
harvest_off: false,
tile_id: None,
yaw: 0.0,
pitch: 0.0,
roll: 0.0,
draw_scale: 1.0,
sprite_mode: None,
growth_progress: None,
presentation_state: None,
channel_start_tick: None,
channel_end_tick: None,
harvest_drop_templates: vec![],
}];
let ids = route_item_template_candidates(
&placed,
Some(cid),
&HashMap::new(),
&blueprints,
&nodes,
&[],
None,
);
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 sellable_route_candidates_follow_npc_buy_lists() {
use flatland_protocol::NpcView;
fn npc(id: &str, can_trade: bool, buy_templates: &[&str]) -> NpcView {
NpcView {
id: id.into(),
label: id.into(),
role: "merchant".into(),
x: 0.0,
y: 0.0,
building_id: None,
entity_id: None,
life_state: None,
hp_pct: None,
can_trade,
buy_templates: buy_templates.iter().map(|t| (*t).into()).collect(),
tile_id: None,
behavior_state: None,
presentation_state: None,
sprite_mode: None,
paperdoll_ref: None,
draw_scale: 1.0,
yaw: None,
perception_fov_deg: None,
perception_sight_m: None,
perception_hear_m: None,
quest_verbs: Vec::new(),
}
}
let templates = vec![
"carrot".into(),
"carrot_wild".into(),
"carrot_seed".into(),
"oak_log".into(),
];
let npcs = vec![
npc("maris", true, &["carrot"]),
npc("eli", true, &["carrot_seed"]),
npc("wildlife", false, &["oak_log"]),
];
assert_eq!(
sellable_route_item_template_candidates(&templates, &npcs, Some("maris")),
vec!["carrot"]
);
assert_eq!(
sellable_route_item_template_candidates(&templates, &npcs, None),
vec!["carrot", "carrot_seed"]
);
}
#[test]
fn trade_npc_candidates_mark_non_overlapping_merchants() {
use flatland_protocol::NpcView;
fn npc(id: &str, label: &str, can_trade: bool, buy_templates: &[&str], x: f32) -> NpcView {
NpcView {
id: id.into(),
label: label.into(),
role: "merchant".into(),
x,
y: 0.0,
building_id: None,
entity_id: None,
life_state: None,
hp_pct: None,
can_trade,
buy_templates: buy_templates.iter().map(|t| (*t).into()).collect(),
tile_id: None,
behavior_state: None,
presentation_state: None,
sprite_mode: None,
paperdoll_ref: None,
draw_scale: 1.0,
yaw: None,
perception_fov_deg: None,
perception_sight_m: None,
perception_hear_m: None,
quest_verbs: Vec::new(),
}
}
let route = vec!["carrot".into(), "potato".into()];
let npcs = vec![
npc("mira_market", "Mira", true, &[], 10.0),
npc("ada_broker", "Ada", true, &["lumber", "oak_log"], 5.0),
npc("maris_cook", "Maris", true, &["carrot"], 20.0),
npc("wildlife", "Wolf", false, &["carrot"], 1.0),
];
let cands = trade_npc_candidates(&npcs, 0.0, 0.0, &route);
assert_eq!(cands.len(), 3, "only can_trade NPCs: {cands:?}");
assert!(cands[0].buys_route_item && cands[0].id == "maris_cook");
assert!(!cands.iter().any(|c| c.id == "wildlife"));
let ada = cands.iter().find(|c| c.id == "ada_broker").unwrap();
assert!(!ada.buys_route_item);
let mira = cands.iter().find(|c| c.id == "mira_market").unwrap();
assert!(!mira.buys_route_item);
assert!(any_trade_npc_buys_route_item(&npcs, &route));
assert!(!any_trade_npc_buys_route_item(
&npcs,
&["iron_ingot".into()]
));
assert!(sell_merchant_empty_reason(Some("mira_market"), &npcs, &route)
.contains("empty buy list"));
assert!(sell_merchant_empty_reason(Some("ada_broker"), &npcs, &route)
.contains("doesn't buy any of your route items"));
assert!(sell_merchant_empty_reason(None, &npcs, &["iron_ingot".into()])
.contains("no trade NPC buys"));
}
#[test]
fn route_item_template_includes_harvest_loot_table_drops() {
use flatland_protocol::{ResourceNodeState, ResourceNodeView};
use std::collections::HashMap;
let nodes = vec![ResourceNodeView {
id: "crop-carrot-1".into(),
label: "Wild carrots".into(),
x: 1.0,
y: 1.0,
z: 0.0,
item_template: "carrot_wild".into(),
state: ResourceNodeState::Available,
blocking: false,
blocking_radius_m: 0.8,
harvest_off: false,
tile_id: None,
yaw: 0.0,
pitch: 0.0,
roll: 0.0,
draw_scale: 1.0,
sprite_mode: None,
growth_progress: None,
presentation_state: None,
channel_start_tick: None,
channel_end_tick: None,
harvest_drop_templates: vec!["carrot".into(), "carrot_seed".into()],
}];
let ids =
route_item_template_candidates(&[], None, &HashMap::new(), &[], &nodes, &[], None);
assert!(
ids.contains(&"carrot_seed".to_string()),
"deposit filter should list seeds from harvest loot tables: {ids:?}"
);
assert!(
!ids.contains(&"carrot_wild".to_string()),
"harvest_node templates are not deposit stacks: {ids:?}"
);
}
#[test]
fn route_item_candidates_drop_harvest_node_catalog_entries() {
use flatland_protocol::{ResourceNodeState, ResourceNodeView};
use std::collections::HashMap;
let rocks = "3ee51931-189c-4726-828b-dffb1a3d1fc4";
let stone = "592fe396-cc6e-42d8-8554-4080c3b19036";
let nodes = vec![ResourceNodeView {
id: "rocks-1".into(),
label: "Rocks".into(),
x: 1.0,
y: 1.0,
z: 0.0,
item_template: rocks.into(),
state: ResourceNodeState::Available,
blocking: false,
blocking_radius_m: 0.8,
harvest_off: false,
tile_id: None,
yaw: 0.0,
pitch: 0.0,
roll: 0.0,
draw_scale: 1.0,
sprite_mode: None,
growth_progress: None,
presentation_state: None,
channel_start_tick: None,
channel_end_tick: None,
harvest_drop_templates: vec![stone.into()],
}];
let mut catalog = HashMap::new();
catalog.insert(
rocks.to_string(),
ItemCatalogEntryView {
template_id: rocks.into(),
display_name: "Rocks".into(),
category: "harvest_node".into(),
seed_for: None,
},
);
catalog.insert(
stone.to_string(),
ItemCatalogEntryView {
template_id: stone.into(),
display_name: "Rough Stone".into(),
category: "resource".into(),
seed_for: None,
},
);
let ids = route_item_template_candidates(
&[],
None,
&HashMap::new(),
&[],
&nodes,
&[rocks.into()],
Some(&catalog),
);
assert!(
ids.contains(&stone.to_string()),
"loot drops stay selectable: {ids:?}"
);
assert!(
!ids.contains(&rocks.to_string()),
"Rocks harvest_node must not appear in deposit filter: {ids:?}"
);
}
#[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 build_job_yaml_includes_list_on_market_stop() {
let mut ed = WorkerRouteEditorState::new("w1".into(), "Laborer".into(), None);
let _ = ed.insert_stop(WorkerRouteStop::ListOnMarket {
template: "carrot".into(),
list_all: true,
hall_id: Some("town_market".into()),
});
let yaml = ed.build_job_yaml().expect("yaml");
assert!(yaml.contains("list_on_market"), "{yaml}");
assert!(yaml.contains("template: carrot"), "{yaml}");
assert!(yaml.contains("hall_id: town_market"), "{yaml}");
assert!(yaml.contains("list_all: true"), "{yaml}");
}
#[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(),
|_| "Food Pad".into(),
);
assert_eq!(summary, "withdraw all iron_ore from Iron Ore Container");
assert!(!summary.contains("uuid"));
}
#[test]
fn summary_resolved_uses_plot_labels() {
let plot_id = uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap();
let cultivate = WorkerRouteStop::CultivatePlot { plot_id };
let plant = WorkerRouteStop::PlantPlot {
plot_id,
seed_template: "potato_seed".into(),
};
let harvest = WorkerRouteStop::HarvestPlot { plot_id };
let friendly = |id: &uuid::Uuid| {
assert_eq!(*id, plot_id);
"Madsin — Starter Town East — Food Pad".to_string()
};
let blank = |_: &str| String::new();
assert_eq!(
cultivate.summary_resolved(blank, blank, blank, friendly),
"cultivate Madsin — Starter Town East — Food Pad"
);
assert_eq!(
plant.summary_resolved(blank, blank, blank, friendly),
"plant potato_seed on Madsin — Starter Town East — Food Pad"
);
assert_eq!(
harvest.summary_resolved(blank, blank, blank, friendly),
"harvest Madsin — Starter Town East — Food Pad"
);
let named = cultivate.summary_resolved(blank, blank, blank, friendly);
assert!(
!named.contains("19fe35f"),
"resolved plot summary must not include hex id: {named}"
);
let raw = cultivate.summary();
assert_eq!(raw, "cultivate plot 19fe35f0");
}
#[test]
fn list_filter_row_matches_name_and_distance() {
assert!(list_filter_row_matches(
"oak",
None,
&["Oak Tree", "oak_log"]
));
assert!(!list_filter_row_matches(
"pine",
None,
&["Oak Tree", "oak_log"]
));
assert!(list_filter_row_matches(
"oak 50m",
Some(40.0),
&["Oak Tree"]
));
assert!(!list_filter_row_matches(
"oak 50m",
Some(60.0),
&["Oak Tree"]
));
assert!(list_filter_row_matches("", Some(999.0), &["anything"]));
}
#[test]
fn node_candidates_sort_from_lodging_anchor_not_player() {
use flatland_protocol::{ResourceNodeState, ResourceNodeView};
fn node(id: &str, label: &str, x: f32) -> ResourceNodeView {
ResourceNodeView {
id: id.into(),
label: label.into(),
x,
y: 0.0,
z: 0.0,
item_template: "oak_log".into(),
state: ResourceNodeState::Available,
blocking: true,
blocking_radius_m: 0.8,
harvest_off: false,
tile_id: None,
yaw: 0.0,
pitch: 0.0,
roll: 0.0,
draw_scale: 1.0,
sprite_mode: None,
presentation_state: None,
growth_progress: None,
channel_start_tick: None,
channel_end_tick: None,
harvest_drop_templates: vec![],
}
}
let nodes = vec![node("far", "Far Oak", 100.0), node("near", "Near Oak", 5.0)];
let sorted = node_candidates(&nodes, 0.0, 0.0);
assert_eq!(sorted[0].id, "near");
assert_eq!(sorted[1].id, "far");
assert!((sorted[0].dist - 5.0).abs() < 0.01);
let stable = node_candidates_stable(&nodes);
assert!(
stable[0].label.starts_with("Far Oak ("),
"got {}",
stable[0].label
);
assert!(
stable[1].label.starts_with("Near Oak ("),
"got {}",
stable[1].label
);
assert!(stable[0].dist.is_nan());
}
#[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,
picked: BTreeSet::new(),
nodes: Vec::new(),
});
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);
}
}