use serde::{Deserialize, Serialize}; use std::{ collections::{HashMap, HashSet}, ops::Range, time::Duration, }; use bevy::prelude::*; use crate::{ animals::{Animal, AnimalKind, MAX_TAME, Tame}, building::{Blueprints, BuildKind}, climate::{COLD_TEMP, WARM_TEMP, Warmth, outdoor_temperature, sowing_season, tile_temperature}, colonist::{Colonist, Path, repath}, designations::Designations, economy::{ColonyGoals, Stock, in_colony}, family::{Child, Family, Home, Homes}, food::{EAT_TICKS, Hunger, STARVING, find_food, portion, wants_food}, items::{ CARRY_CAPACITY, Carrying, Item, ItemAssets, ItemKind, ItemQuery, STACK_LIMIT, drop_items, room_for, take_items, }, light::{ LIGHT_LOOKAHEAD_TICKS, LIT_BRIGHTNESS, LightMap, brightness, dark_soon, light_from, line_of_sight, }, mood::{Ate, Sick}, needs::{EXHAUSTED_REST, Rest, SleepContext, done_sleeping, wants_sleep}, pathfinding::{ ColonyReach, WANDER_SEARCH, can_step, find_bridged_path, find_nearby_path, find_path, find_path_adjacent, find_path_adjacent_within, is_adjacent, nearest_of, nearest_tile, search_reach, }, paths::busy_path, population::ColonyLog, raids::Raider, rng::SimRng, sim::{SimSystems, SimTick, hour_of_day}, skills::{POOR_LEVEL, Skill, Skills, WorkCarry, speed}, social::{FRIEND, Relations}, structures::{Fuel, Structure, StructureAssets, StructureKind, spawn_structure}, threats::{Health, INJURED, TEND_WORK, TENDED_TICKS, Tended, needs_tending, tend_quality}, trading::{TRADE_WORK, Trader, find_trade}, world::{Plant, Terrain, TileChanged, TilePos, WorldMap}, worldgen::WorldGenerated, zones::{ StoragePriority, Zones, can_zone, find_stockpile_site, new_stockpile_tiles, plan_expansion, }, }; pub struct JobsPlugin; impl Plugin for JobsPlugin { fn build(&self, app: &mut App) { app.insert_resource(SimRng::new(0x5EED)) .init_resource::() .add_message::() .add_message::() .add_systems( Update, ( clear_reservations.run_if(on_message::), handle_orders, ), ) .add_systems(FixedUpdate, choose_jobs.in_set(SimSystems::Decide)) .add_systems(FixedUpdate, run_jobs.in_set(SimSystems::Act)); } } pub const EXPAND_WORK: u32 = 240; pub const REFUEL_BELOW_SHARE: u32 = 3; pub const CAMPFIRE_MIN_FUEL: u32 = 3 * crate::sim::TICKS_PER_HOUR as u32; pub const HOME_RADIUS: f32 = 10.0; pub const BED_STOCKPILE_CLEARANCE: i32 = 3; const LIT_WORK_RATE: u32 = 2; const DARK_WORK_RATE: u32 = 1; pub const EXPANSION_TILES: usize = 8; pub const MIN_EMPTY_STOCKPILE_TILES: usize = 3; const SEARCH_TILES: usize = 15000; const FORAGE_SEARCH_TILES: usize = 5000; const WANDER_RADIUS: i32 = 8; const IDLE_SECS: Range = 1.0..4.0; const IDLE_AFTER_ORDER_SECS: f32 = 10.0; const WORK_PAUSE_SECS: Range = 0.2..0.8; const NEIGHBORS: [IVec2; 8] = [ IVec2::new(1, 0), IVec2::new(-1, 0), IVec2::new(0, 1), IVec2::new(0, -1), IVec2::new(1, 1), IVec2::new(1, -1), IVec2::new(-1, 1), IVec2::new(-1, -1), ]; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum WorkType { Food, Build, Gather, Haul, Tame, Craft, Doctor, } impl WorkType { pub const ALL: [WorkType; 7] = [ WorkType::Food, WorkType::Build, WorkType::Gather, WorkType::Haul, WorkType::Tame, WorkType::Craft, WorkType::Doctor, ]; pub fn label(self) -> &'static str { match self { WorkType::Food => "Food", WorkType::Build => "Build", WorkType::Gather => "Gather", WorkType::Haul => "Haul", WorkType::Tame => "Tame", WorkType::Craft => "Craft", WorkType::Doctor => "Doctor", } } } #[derive(Component, Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WorkPriorities { disabled: Vec, } impl WorkPriorities { pub fn allows(&self, work: WorkType) -> bool { !self.disabled.contains(&work) } pub fn toggle(&mut self, work: WorkType) { if self.allows(work) { self.disabled.push(work); } else { self.disabled.retain(|w| *w != work); } } } #[derive(Component, Default, Debug, Clone, PartialEq)] pub struct CurrentJob(pub Option); #[derive(Debug, Clone, Copy, PartialEq)] pub struct Job { pub task: Task, pub ordered: bool, } #[derive(Debug, Clone, Copy, PartialEq)] pub enum Task { MoveTo(UVec2), Work { site: UVec2, kind: WorkKind, progress: u32, }, Haul { pickup: Option, to: UVec2, }, ExpandStockpile { site: UVec2, progress: u32, }, Build { site: UVec2, kind: BuildKind, pickup: Option, progress: u32, }, Sleep { spot: UVec2, }, Eat { pickup: Option, progress: u32, }, Hunt { animal: Entity, kind: AnimalKind, progress: u32, }, Craft { recipe: Recipe, pickup: Option, station: UVec2, progress: u32, }, Equip { pickup: UVec2, }, Tend { patient: Entity, pickup: Option, progress: u32, }, Fight { enemy: Entity, progress: u32, }, Butcher { site: UVec2, progress: u32, }, Tame { animal: Entity, kind: AnimalKind, progress: u32, }, Trade { trader: Entity, pickup: Option, give: ItemKind, count: u32, want: ItemKind, receive: u32, progress: u32, }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Recipe { Meal, Tools, } impl Recipe { pub fn stations(self) -> &'static [StructureKind] { match self { Recipe::Meal => &[StructureKind::Campfire, StructureKind::Hearth], Recipe::Tools => &[StructureKind::Workbench], } } pub fn accepts(self, kind: ItemKind) -> bool { match self { Recipe::Meal => kind.is_raw_food(), Recipe::Tools => kind == ItemKind::Iron, } } pub fn input(self) -> u32 { match self { Recipe::Meal => COOK_INPUT, Recipe::Tools => TOOL_IRON, } } pub fn work(self) -> u32 { match self { Recipe::Meal => COOK_WORK, Recipe::Tools => TOOL_WORK, } } pub fn output(self) -> ItemKind { match self { Recipe::Meal => ItemKind::Meal, Recipe::Tools => ItemKind::Tools, } } } pub const TOOL_IRON: u32 = 3; pub const TOOL_WORK: u32 = 600; pub const TOOL_LIFE_TICKS: u32 = 2 * 24 * crate::sim::TICKS_PER_HOUR as u32; #[derive(Component, Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct Tool { pub wear: u32, } impl Task { pub fn progress(&self) -> Option { match *self { Task::Work { progress, .. } | Task::ExpandStockpile { progress, .. } | Task::Build { progress, .. } | Task::Hunt { progress, .. } | Task::Craft { progress, .. } | Task::Butcher { progress, .. } | Task::Tame { progress, .. } | Task::Tend { progress, .. } | Task::Fight { progress, .. } | Task::Trade { progress, .. } | Task::Eat { progress, .. } => Some(progress), _ => None, } } } pub fn tool_rate(rate: u32, tool: bool) -> u32 { if tool { (rate * 3).div_ceil(2) } else { rate } } pub fn work_rate(light: &LightMap, tile: UVec2) -> u32 { if light.is_lit(tile) { LIT_WORK_RATE } else { DARK_WORK_RATE } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WorkKind { Chop, Mine, Harvest, Sow, } impl WorkKind { pub fn work(self) -> u32 { match self { WorkKind::Chop => 480, WorkKind::Mine => 720, WorkKind::Harvest => 240, WorkKind::Sow => 160, } } fn complete(self, map: &mut WorldMap, site: UVec2) { match self { WorkKind::Chop => map.set_plant(site, None), WorkKind::Mine => map.set(site, Terrain::Gravel), WorkKind::Harvest => { if map.plant(site.as_ivec2()) == Some(Plant::Crop) { map.set_plant(site, None); } map.set_growth(site, 0.0); } WorkKind::Sow => { map.set_plant(site, Some(Plant::Crop)); map.set_growth(site, 0.0); } } } } pub struct SiteWork { pub kind: WorkKind, pub item: ItemKind, pub count: u32, } pub fn site_work(map: &WorldMap, site: UVec2) -> Option { let p = site.as_ivec2(); let harvest = |item, count| SiteWork { kind: WorkKind::Harvest, item, count, }; match map.plant(p) { Some(Plant::Tree) => { return Some(SiteWork { kind: WorkKind::Chop, item: ItemKind::Wood, count: 10, }); } Some(Plant::BerryBush) if map.ripe_plant(p).is_some() => { return Some(harvest(ItemKind::Berries, 8)); } Some(Plant::Crop) if map.ripe_plant(p).is_some() => { return Some(harvest(ItemKind::Vegetables, 10)); } Some(Plant::Herb) if map.ripe_plant(p).is_some() => { return Some(harvest(ItemKind::Herbs, 3)); } _ => {} } let (item, count) = match map.get(p)? { Terrain::Stone => (ItemKind::Stone, 10), Terrain::IronOre => (ItemKind::Iron, 10), Terrain::GoldOre => (ItemKind::Gold, 5), _ => return None, }; Some(SiteWork { kind: WorkKind::Mine, item, count, }) } #[derive(Component, Debug)] pub struct IdleTimer(Timer); impl Default for IdleTimer { fn default() -> Self { Self(Timer::from_seconds(IDLE_SECS.start, TimerMode::Once)) } } impl IdleTimer { fn restart(&mut self, secs: f32) { self.0.set_duration(Duration::from_secs_f32(secs)); self.0.reset(); } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Target { Site(UVec2), Pickup(UVec2), Storage(UVec2), StockpileExpansion, SleepSpot(UVec2), Animal(Entity), Station(UVec2), Patient(Entity), } #[derive(Resource, Default, Debug)] pub struct Reservations(HashMap); impl Reservations { pub fn reserve(&mut self, target: Target, by: Entity) { self.0.insert(target, by); } pub fn holder(&self, target: Target) -> Option { self.0.get(&target).copied() } pub fn is_free(&self, target: Target) -> bool { self.holder(target).is_none() } pub fn release(&mut self, target: Target) { self.0.remove(&target); } pub fn release_all(&mut self, by: Entity) { self.0.retain(|_, holder| *holder != by); } } #[derive(Message, Debug, Clone, Copy)] pub struct Order { pub colonist: Entity, pub tile: UVec2, } fn clear_reservations(mut reservations: ResMut) { reservations.0.clear(); } fn handle_orders( mut orders: MessageReader, map: Res, zones: Res, items: Query<(&Item, &TilePos)>, animals: Query<(Entity, &TilePos, &Animal), Without>, mut reservations: ResMut, mut colonists: Query<(&TilePos, &mut Path, &mut CurrentJob), With>, ) { for order in orders.read() { let Ok((tile, mut path, mut job)) = colonists.get_mut(order.colonist) else { continue; }; let loose_item = items .iter() .find(|(_, pos)| pos.0 == order.tile && !zones.is_stockpile(pos.0)); let animal = animals.iter().find(|(_, pos, _)| pos.0 == order.tile); let task = if let Some((animal, _, a)) = animal { Some(Task::Hunt { animal, kind: a.kind, progress: 0, }) } else if let Some(work) = site_work(&map, order.tile) { Some(Task::Work { site: order.tile, kind: work.kind, progress: 0, }) } else if let Some((item, _)) = loose_item { let anywhere = ColonyReach::default(); find_storage( &map, &zones, &reservations, &anywhere, &items, order.tile, item.kind, ) .map(|to| Task::Haul { pickup: Some(order.tile), to, }) } else { None }; if let Some(task) = task { reservations.release_all(order.colonist); reserve_task(&mut reservations, task, order.colonist); path.0.truncate(1); job.0 = Some(Job { task, ordered: true, }); } else if let Some(new_path) = repath(&map, tile.0, &path.0, order.tile) { reservations.release_all(order.colonist); path.0 = new_path; job.0 = Some(Job { task: Task::MoveTo(order.tile), ordered: true, }); } } } fn reserve_task(reservations: &mut Reservations, task: Task, by: Entity) { match task { Task::MoveTo(_) | Task::Fight { .. } => {} Task::Work { site, .. } => reservations.reserve(Target::Site(site), by), Task::Haul { pickup, to } => { if let Some(pickup) = pickup { reservations.reserve(Target::Pickup(pickup), by); } reservations.reserve(Target::Storage(to), by); } Task::ExpandStockpile { .. } => reservations.reserve(Target::StockpileExpansion, by), Task::Sleep { spot } => reservations.reserve(Target::SleepSpot(spot), by), Task::Eat { pickup, .. } => { if let Some(pickup) = pickup { reservations.reserve(Target::Pickup(pickup), by); } } Task::Hunt { animal, .. } | Task::Tame { animal, .. } => { reservations.reserve(Target::Animal(animal), by) } Task::Butcher { site, .. } => reservations.reserve(Target::Pickup(site), by), Task::Equip { pickup } => reservations.reserve(Target::Pickup(pickup), by), Task::Trade { pickup, .. } => { if let Some(pickup) = pickup { reservations.reserve(Target::Pickup(pickup), by); } } Task::Tend { patient, pickup, .. } => { reservations.reserve(Target::Patient(patient), by); if let Some(pickup) = pickup { reservations.reserve(Target::Pickup(pickup), by); } } Task::Craft { pickup, station, .. } => { reservations.reserve(Target::Station(station), by); if let Some(pickup) = pickup { reservations.reserve(Target::Pickup(pickup), by); } } Task::Build { site, pickup, .. } => { reservations.reserve(Target::Site(site), by); if let Some(pickup) = pickup { reservations.reserve(Target::Pickup(pickup), by); } } } } const GATHER_RETRY_TICKS: u64 = 300; const HAUL_RETRY_TICKS: u64 = 120; const FOOD_RETRY_TICKS: u64 = 60; #[derive(Default, Clone, Copy)] pub struct Memo { mask: u32, until: u64, } impl Memo { fn skips(self, mask: u32, tick: u64) -> bool { self.mask == mask && tick < self.until } } #[derive(Default)] pub struct GatherMemo { gather: Memo, forage: Memo, haul: Memo, build: Memo, food: HashMap, warm: Option<(u64, HashSet)>, } pub fn find_work( map: &WorldMap, reservations: &Reservations, from: UVec2, wanted: impl Fn(UVec2, ItemKind) -> bool, ) -> Option { find_work_within(map, reservations, from, SEARCH_TILES, wanted) } pub fn find_work_within( map: &WorldMap, reservations: &Reservations, from: UVec2, max_tiles: usize, wanted: impl Fn(UVec2, ItemKind) -> bool, ) -> Option { let mut found = None; nearest_tile(map, from, max_tiles, |stand| { found = NEIGHBORS.iter().find_map(|&d| { let site = stand.as_ivec2() + d; if site.x < 0 || site.y < 0 { return None; } let site = site.as_uvec2(); let work = site_work(map, site)?; (wanted(site, work.item) && reservations.is_free(Target::Site(site))).then_some( Task::Work { site, kind: work.kind, progress: 0, }, ) }); found.is_some() })?; found } fn stacks_by_tile<'a>( items: impl IntoIterator, ) -> HashMap { let mut stacks: HashMap = HashMap::new(); for (item, pos) in items { stacks.entry(pos.0).or_insert((item.kind, 0)).1 += item.count; } stacks } fn storage_candidates<'z>( zones: &'z Zones, reservations: &'z Reservations, stacks: &'z HashMap, kind: ItemKind, ) -> impl Iterator + 'z { zones.stockpile_tiles().filter(move |&t| { zones.accepts(t, kind) && reservations.is_free(Target::Storage(t)) && match stacks.get(&t) { None => true, Some(&(other, count)) => other == kind && count < STACK_LIMIT, } }) } pub fn find_storage<'a>( map: &WorldMap, zones: &Zones, reservations: &Reservations, reach: &ColonyReach, items: impl IntoIterator + Clone, from: UVec2, kind: ItemKind, ) -> Option { let stacks = stacks_by_tile(items); StoragePriority::DESCENDING .into_iter() .find_map(|priority| { let candidates: HashSet = storage_candidates(zones, reservations, &stacks, kind) .filter(|&t| zones.rule(t).priority == priority) .collect(); reach.nearest(map, from, SEARCH_TILES, &candidates) }) } pub fn find_haul( map: &WorldMap, zones: &Zones, reservations: &Reservations, reach: &ColonyReach, homes: &Homes, items: &[(Item, TilePos)], from: UVec2, ) -> Option { let stacks = stacks_by_tile(items.iter().map(|(i, p)| (i, p))); let mut best: HashMap = HashMap::new(); let misplaced = |item: &Item, tile: UVec2, best: &mut HashMap| { if !zones.accepts(tile, item.kind) { return true; } let top = *best.entry(item.kind).or_insert_with(|| { storage_candidates(zones, reservations, &stacks, item.kind) .map(|t| zones.rule(t).priority) .max() .unwrap_or(StoragePriority::Low) }); zones.rule(tile).priority < top }; let mut room: HashMap = HashMap::new(); let loose: HashSet = items .iter() .filter(|(item, pos)| { reservations.is_free(Target::Pickup(pos.0)) && reach.allows(pos.0) && !(item.kind == ItemKind::Meal && homes.is_home(pos.0)) && misplaced(item, pos.0, &mut best) && *room.entry(item.kind).or_insert_with(|| { storage_candidates(zones, reservations, &stacks, item.kind) .next() .is_some() }) }) .map(|(_, pos)| pos.0) .collect(); if loose.is_empty() { return None; } let pickup = reach.nearest(map, from, SEARCH_TILES, &loose)?; let (item, _) = items.iter().find(|(_, pos)| pos.0 == pickup)?; let refs = items.iter().map(|(i, p)| (i, p)); let to = find_storage(map, zones, reservations, reach, refs, pickup, item.kind)?; Some(Task::Haul { pickup: Some(pickup), to, }) } pub fn sowable(map: &WorldMap, zones: &Zones, site: UVec2) -> bool { zones.is_farm(site) && map.plant(site.as_ivec2()).is_none() } pub fn find_sowing( map: &WorldMap, zones: &Zones, reservations: &Reservations, from: UVec2, ) -> Option { let open: HashSet = zones .farm_tiles() .filter(|&p| sowable(map, zones, p) && reservations.is_free(Target::Site(p))) .collect(); let site = nearest_of(map, from, SEARCH_TILES, &open)?; Some(Task::Work { site, kind: WorkKind::Sow, progress: 0, }) } pub const HUNT_RADIUS: f32 = 60.0; pub const HUNT_RANGE: f32 = 6.0; pub type Wildlife = (Entity, UVec2, AnimalKind, bool); fn nearest_reachable( map: &WorldMap, reservations: &Reservations, animals: &[Wildlife], from: UVec2, pick: impl Fn(&Wildlife) -> bool, ) -> Option { let mut candidates: Vec<&Wildlife> = animals .iter() .filter(|a| { pick(a) && reservations.is_free(Target::Animal(a.0)) && a.1.as_vec2().distance(from.as_vec2()) <= HUNT_RADIUS }) .collect(); candidates.sort_by(|a, b| { a.1.as_vec2() .distance_squared(from.as_vec2()) .total_cmp(&b.1.as_vec2().distance_squared(from.as_vec2())) }); candidates .into_iter() .find(|a| find_path_adjacent(map, from, a.1).is_some()) .copied() } pub fn find_hunt( map: &WorldMap, reservations: &Reservations, animals: &[Wildlife], from: UVec2, ) -> Option { let herd = |kind: AnimalKind| animals.iter().filter(|a| a.3 && a.2 == kind).count(); let spare_tame = |a: &Wildlife| a.3 && herd(a.2) > 2; let (animal, _, kind, _) = nearest_reachable(map, reservations, animals, from, spare_tame) .or_else(|| nearest_reachable(map, reservations, animals, from, |a| !a.3))?; Some(Task::Hunt { animal, kind, progress: 0, }) } pub fn find_taming( map: &WorldMap, reservations: &Reservations, animals: &[Wildlife], from: UVec2, ) -> Option { if animals.iter().filter(|a| a.3).count() >= MAX_TAME { return None; } let tameable = |a: &Wildlife| !a.3 && !a.2.is_predator(); let (animal, _, kind, _) = nearest_reachable(map, reservations, animals, from, tameable)?; Some(Task::Tame { animal, kind, progress: 0, }) } pub fn stockpile_too_full(zones: &Zones, items: &[(Item, TilePos)]) -> bool { let occupied: HashSet = items.iter().map(|(_, pos)| pos.0).collect(); zones.has_stockpile() && zones.empty_stockpile_tiles(&occupied) < MIN_EMPTY_STOCKPILE_TILES } pub fn find_expansion( map: &WorldMap, zones: &Zones, reservations: &Reservations, items: &[(Item, TilePos)], blueprints: &Blueprints, ) -> Option { if !reservations.is_free(Target::StockpileExpansion) { return None; } let site = if zones.has_stockpile() { if !stockpile_too_full(zones, items) { return None; } *plan_expansion(map, zones, &blueprints.tiles(), EXPANSION_TILES).first()? } else { find_stockpile_site(map, central_item_tile(items)?)? }; Some(Task::ExpandStockpile { site, progress: 0 }) } fn central_item_tile(items: &[(Item, TilePos)]) -> Option { let tiles: Vec = items.iter().map(|(_, pos)| pos.0.as_vec2()).collect(); let centroid = tiles.iter().sum::() / tiles.len().max(1) as f32; items.iter().map(|(_, pos)| pos.0).min_by(|a, b| { a.as_vec2() .distance_squared(centroid) .total_cmp(&b.as_vec2().distance_squared(centroid)) }) } pub struct BuildContext<'a> { pub map: &'a WorldMap, pub zones: &'a Zones, pub reservations: &'a Reservations, pub items: &'a [(Item, TilePos)], pub structures: &'a [(UVec2, StructureKind, u32)], pub planned: &'a [(UVec2, StructureKind)], pub blueprints: &'a Blueprints, pub occupied: &'a HashSet, pub reach: &'a ColonyReach, } impl BuildContext<'_> { fn buildable(&self, tile: UVec2) -> bool { can_zone(self.map, tile.as_ivec2()) && !busy_path(self.map, tile.as_ivec2()) && !self.zones.is_stockpile(tile) && !self.zones.is_farm(tile) && self.map.plant(tile.as_ivec2()).is_none() && !self.occupied.contains(&tile) && self.blueprints.get(tile).is_none() && self.reservations.is_free(Target::Site(tile)) } fn lights(&self) -> impl Iterator + Clone + '_ { self.structures .iter() .map(|&(pos, kind, _)| (pos, kind)) .chain(self.planned.iter().copied()) } fn campfire_needed(&self, home: UVec2) -> bool { let burning = self.structures.iter().any(|&(pos, kind, fuel)| { kind == StructureKind::Campfire && fuel >= CAMPFIRE_MIN_FUEL && pos.as_vec2().distance(home.as_vec2()) <= HOME_RADIUS }); let planned = self .planned .iter() .any(|(_, kind)| *kind == StructureKind::Campfire); !burning && !planned } fn darkest_stockpile_tile(&self, home: UVec2) -> Option { self.zones .stockpile_tiles() .filter(|&t| brightness(light_from(t, self.lights())) < LIT_BRIGHTNESS) .min_by(|a, b| { a.as_vec2() .distance_squared(home.as_vec2()) .total_cmp(&b.as_vec2().distance_squared(home.as_vec2())) .then((a.y, a.x).cmp(&(b.y, b.x))) }) } fn site_near(&self, target: UVec2, max_distance: f32) -> Option { self.site_near_where(target, max_distance, |_| true) } fn site_near_where( &self, target: UVec2, max_distance: f32, extra: impl Fn(UVec2) -> bool, ) -> Option { nearest_tile(self.map, target, SEARCH_TILES, |p| { self.buildable(p) && extra(p) }) .filter(|site| site.as_vec2().distance(target.as_vec2()) <= max_distance) } fn clear_of_stockpile(&self, tile: UVec2) -> bool { self.zones .stockpile_tiles() .chain(self.zones.farm_tiles()) .all(|s| { (s.as_ivec2() - tile.as_ivec2()).abs().max_element() >= BED_STOCKPILE_CLEARANCE }) } fn count(&self, kind: StructureKind) -> usize { let built = self.structures.iter().filter(|s| s.1 == kind).count(); built + self.planned.iter().filter(|p| p.1 == kind).count() } fn bed_anchor(&self, home: UVec2) -> UVec2 { let positions = |kind: StructureKind| { let built = self.structures.iter().map(|s| (s.0, s.1)); built .chain(self.planned.iter().copied()) .filter(move |s| s.1 == kind) .map(|s| s.0) }; let nearest = |kind: StructureKind| { positions(kind).min_by(|a, b| { a.as_vec2() .distance_squared(home.as_vec2()) .total_cmp(&b.as_vec2().distance_squared(home.as_vec2())) }) }; nearest(StructureKind::Bed) .or_else(|| nearest(StructureKind::Campfire)) .unwrap_or(home) } fn material_source(&self, from: UVec2, (kind, amount): (ItemKind, u32)) -> Option { let stacks: Vec<(u32, UVec2)> = self .items .iter() .filter(|(item, pos)| { item.kind == kind && self.reservations.is_free(Target::Pickup(pos.0)) }) .map(|(item, pos)| (item.count, pos.0)) .collect(); if stacks.iter().map(|(count, _)| count).sum::() < amount { return None; } let tiles: HashSet = stacks.into_iter().map(|(_, tile)| tile).collect(); self.reach.nearest(self.map, from, SEARCH_TILES, &tiles) } fn build_task(&self, from: UVec2, site: UVec2, kind: BuildKind) -> Option { let pickup = match kind.cost() { (_, 0) => None, cost => Some(self.material_source(from, cost)?), }; Some(Task::Build { site, kind, pickup, progress: 0, }) } } pub const COOK_INPUT: u32 = 6; pub const COOK_WORK: u32 = 300; pub const COOK_MIN_FUEL: u32 = crate::sim::TICKS_PER_HOUR as u32; pub fn find_campfire(ctx: &BuildContext, from: UVec2) -> Option { let home = ctx.zones.home(from); if !ctx.campfire_needed(home) { return None; } let site = ctx.site_near(home, HOME_RADIUS)?; ctx.build_task(from, site, BuildKind::Structure(StructureKind::Campfire)) } pub fn food_to_fetch(reservations: &Reservations, food_tiles: &HashSet) -> bool { food_tiles .iter() .any(|&p| reservations.is_free(Target::Pickup(p))) } pub fn input_source(ctx: &BuildContext, from: UVec2, recipe: Recipe) -> Option { let sources: HashSet = ctx .items .iter() .filter(|(item, pos)| { recipe.accepts(item.kind) && item.count >= recipe.input() && ctx.reservations.is_free(Target::Pickup(pos.0)) }) .map(|(_, pos)| pos.0) .collect(); ctx.reach.nearest(ctx.map, from, SEARCH_TILES, &sources) } pub const BUTCHER_WORK: u32 = 240; pub fn find_butchering( map: &WorldMap, reservations: &Reservations, items: &[(Item, TilePos)], from: UVec2, ) -> Option { let carcasses: HashSet = items .iter() .filter(|(item, pos)| { item.kind == ItemKind::Carcass && reservations.is_free(Target::Pickup(pos.0)) }) .map(|(_, pos)| pos.0) .collect(); let site = nearest_of(map, from, SEARCH_TILES, &carcasses)?; Some(Task::Butcher { site, progress: 0 }) } pub fn find_workbench(ctx: &BuildContext, from: UVec2) -> Option { if ctx.count(StructureKind::Workbench) > 0 { return None; } let home = ctx.zones.home(from); let site = ctx.site_near(home, HOME_RADIUS * 1.5)?; ctx.build_task(from, site, BuildKind::Structure(StructureKind::Workbench)) } pub fn tool_source( map: &WorldMap, reservations: &Reservations, items: &[(Item, TilePos)], from: UVec2, ) -> Option { let tools: HashSet = items .iter() .filter(|(item, pos)| { item.kind == ItemKind::Tools && reservations.is_free(Target::Pickup(pos.0)) }) .map(|(_, pos)| pos.0) .collect(); if tools.is_empty() { return None; } nearest_of(map, from, SEARCH_TILES, &tools) } pub fn find_crafting( ctx: &BuildContext, from: UVec2, recipe: Recipe, home: Option<&Home>, ) -> Option { let usable = |pos: UVec2, kind: StructureKind| { kind != StructureKind::Hearth || home.is_some_and(|h| h.contains(pos)) }; let station = ctx .structures .iter() .filter(|&&(pos, kind, fuel)| { recipe.stations().contains(&kind) && usable(pos, kind) && fuel >= COOK_MIN_FUEL && ctx.reservations.is_free(Target::Station(pos)) }) .min_by_key(|&&(pos, kind, _)| { let d = pos.as_ivec2() - from.as_ivec2(); ( kind != StructureKind::Hearth, d.length_squared(), pos.y, pos.x, ) }) .map(|&(pos, ..)| pos)?; let pickup = input_source(ctx, from, recipe)?; Some(Task::Craft { recipe, pickup: Some(pickup), station, progress: 0, }) } pub fn find_lighting(ctx: &BuildContext, from: UVec2, dark: bool) -> Option { let home = ctx.zones.home(from); let (kind, site) = if ctx.campfire_needed(home) { (StructureKind::Campfire, ctx.site_near(home, HOME_RADIUS)?) } else if !dark { return None; } else { let dark = ctx.darkest_stockpile_tile(home)?; let reach = StructureKind::Torch.radius() * 0.6; (StructureKind::Torch, ctx.site_near(dark, reach)?) }; ctx.build_task(from, site, BuildKind::Structure(kind)) } pub const SHOT_WORK: u32 = 90; pub const SHOT_DAMAGE: f32 = 0.2; const FIGHT_SEARCH: usize = 4000; const FIGHT_STEPS: usize = 4; pub const DANGER_RANGE: i32 = 6; const VISIT_CHANCE: f32 = 0.4; const VISIT_RANGE: i32 = 30; const VISIT_SEARCH: usize = 3000; pub const DEFEND_RANGE: i32 = 15; const FLEE_STEPS: f32 = 10.0; fn chebyshev(a: UVec2, b: UVec2) -> i32 { (a.as_ivec2() - b.as_ivec2()).abs().max_element() } pub fn flee_from(map: &WorldMap, from: UVec2, threat: UVec2) -> Option> { let away = (from.as_ivec2() - threat.as_ivec2()) .as_vec2() .normalize_or(Vec2::X); let goal = (from.as_ivec2() + (away * FLEE_STEPS).round().as_ivec2()) .clamp(IVec2::ZERO, map.size.as_ivec2() - 1); let open = nearest_tile(map, goal.as_uvec2(), 200, |p| { map.move_cost(p.as_ivec2()).is_some() })?; find_path(map, from, open).filter(|route| !route.is_empty()) } pub fn find_tending( map: &WorldMap, patients: &[(Entity, UVec2)], reservations: &Reservations, reach: &ColonyReach, items: &[(Item, TilePos)], doctor: Entity, from: UVec2, ) -> Option { let &(patient, _) = patients .iter() .filter(|&&(patient, at)| { patient != doctor && reach.allows_near(at) && reservations.is_free(Target::Patient(patient)) }) .min_by_key(|(_, at)| { let d = at.as_ivec2() - from.as_ivec2(); (d.length_squared(), at.y, at.x) })?; let herbs: HashSet = items .iter() .filter(|(item, pos)| { item.kind == ItemKind::Herbs && reservations.is_free(Target::Pickup(pos.0)) }) .map(|(_, pos)| pos.0) .collect(); let pickup = (!herbs.is_empty()) .then(|| reach.nearest(map, from, SEARCH_TILES, &herbs)) .flatten(); Some(Task::Tend { patient, pickup, progress: 0, }) } pub fn chilly_soon(tick: SimTick) -> bool { let later = SimTick(tick.0 + LIGHT_LOOKAHEAD_TICKS); outdoor_temperature(tick) < COLD_TEMP || outdoor_temperature(later) < COLD_TEMP } pub fn warm_tiles( map: &WorldMap, structures: &[(UVec2, StructureKind, u32)], outdoor: f32, ) -> HashSet { let fires: Vec<(UVec2, StructureKind)> = structures .iter() .filter(|(_, kind, _)| kind.heat() > 0.0) .map(|&(pos, kind, _)| (pos, kind)) .collect(); let taken: HashSet = structures.iter().map(|(pos, ..)| *pos).collect(); let warm: HashSet = fires .iter() .flat_map(|&(pos, kind)| { let r = kind.radius().ceil() as i32; (-r..=r).flat_map(move |dy| (-r..=r).map(move |dx| pos.as_ivec2() + IVec2::new(dx, dy))) }) .filter(|&p| map.in_bounds(p) && map.move_cost(p).is_some()) .map(|p| p.as_uvec2()) .filter(|p| !taken.contains(p)) .filter(|&p| { let near = fires .iter() .copied() .filter(|(pos, kind)| pos.as_vec2().distance(p.as_vec2()) < kind.radius()); tile_temperature(map, outdoor, p, near) >= WARM_TEMP }) .collect(); warm } pub fn find_refuel(ctx: &BuildContext, from: UVec2) -> Option { let home = ctx.zones.home(from); let (site, kind, _) = ctx .structures .iter() .filter(|&&(pos, kind, fuel)| { kind.fuel_ticks() .is_some_and(|full| fuel < full / REFUEL_BELOW_SHARE) && (kind == StructureKind::Hearth || pos.as_vec2().distance(home.as_vec2()) <= HOME_RADIUS * 2.0) && ctx.reservations.is_free(Target::Site(pos)) }) .min_by_key(|(.., fuel)| *fuel)?; ctx.build_task(from, *site, BuildKind::Refuel(*kind)) } pub fn find_bed_site(ctx: &BuildContext, colonists: usize, from: UVec2) -> Option { if ctx.count(StructureKind::Bed) >= colonists { return None; } let anchor = ctx.bed_anchor(ctx.zones.home(from)); let site = ctx.site_near_where(anchor, HOME_RADIUS * 1.5, |p| ctx.clear_of_stockpile(p))?; ctx.build_task(from, site, BuildKind::Structure(StructureKind::Bed)) } pub fn find_blueprint_work(ctx: &BuildContext, from: UVec2) -> Option { let mut stock: HashMap = HashMap::new(); for (item, pos) in ctx.items { if ctx.reservations.is_free(Target::Pickup(pos.0)) { *stock.entry(item.kind).or_insert(0) += item.count; } } let mut stands: HashSet = HashSet::new(); for (&site, &kind) in &ctx.blueprints.0 { let (material, amount) = kind.cost(); let affordable = amount == 0 || stock.get(&material).copied().unwrap_or(0) >= amount; let affordable = affordable && ctx.reach.allows_near(site); if !affordable || !ctx.reservations.is_free(Target::Site(site)) { continue; } for d in std::iter::once(IVec2::ZERO).chain(NEIGHBORS) { let stand = site.as_ivec2() + d; if (d == IVec2::ZERO || ctx.map.move_cost(site.as_ivec2()).is_none()) && ctx.map.move_cost(stand).is_some() { stands.insert(stand.as_uvec2()); } } } let reach = search_reach(SEARCH_TILES) as i32; let in_reach = |p: &UVec2| (p.as_ivec2() - from.as_ivec2()).abs().max_element() <= reach; if !stands.iter().any(in_reach) { return None; } let mut sources: HashMap<(ItemKind, u32), Option> = HashMap::new(); let mut found = None; nearest_tile(ctx.map, from, SEARCH_TILES, |p| { if !stands.contains(&p) { return false; } found = std::iter::once(IVec2::ZERO) .chain(NEIGHBORS) .filter_map(|d| { let site = p.as_ivec2() + d; (site.min_element() >= 0).then(|| site.as_uvec2()) }) .filter(|&site| site == p || ctx.map.move_cost(site.as_ivec2()).is_none()) .find_map(|site| { let kind = ctx.blueprints.get(site)?; if !ctx.reservations.is_free(Target::Site(site)) { return None; } let pickup = match kind.cost() { (_, 0) => None, cost => Some( (*sources .entry(cost) .or_insert_with(|| ctx.material_source(from, cost)))?, ), }; Some(Task::Build { site, kind, pickup, progress: 0, }) }); found.is_some() })?; found } fn on_the_way(map: &WorldMap, jobs: impl Iterator>) -> HashMap { let mut totals = HashMap::new(); for job in jobs.flatten() { if let Task::Work { site, .. } = job.task && let Some(work) = site_work(map, site) { *totals.entry(work.item).or_insert(0) += work.count; } } totals } #[allow(clippy::too_many_arguments)] fn choose_jobs( mut commands: Commands, time: Res