use std::{ collections::{HashMap, HashSet}, ops::Range, time::Duration, }; use bevy::prelude::*; use crate::{ animals::{Animal, AnimalKind}, building::{Blueprints, BuildKind}, colonist::{Colonist, Path, repath}, economy::ColonyGoals, food::{EAT_TICKS, Hunger, STARVING, find_food, portion, wants_food}, items::{ CARRY_CAPACITY, Carrying, Item, ItemAssets, ItemKind, ItemQuery, drop_items, room_for, stock, take_items, }, light::{LIT_BRIGHTNESS, LightMap, brightness, dark_soon, light_from}, needs::{EXHAUSTED_REST, Rest, SleepContext, done_sleeping, wants_sleep}, pathfinding::{find_path, find_path_adjacent, is_adjacent, nearest_tile}, rng::SimRng, sim::{SimSystems, SimTick, hour_of_day}, structures::{Fuel, Structure, StructureAssets, StructureKind, spawn_structure}, world::{Plant, Terrain, TileChanged, TilePos, WorldMap}, worldgen::WorldGenerated, zones::{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 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 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(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, }, Cook { pickup: Option, station: UVec2, progress: u32, }, } 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)); } _ => {} } 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), } #[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 { find_storage(&map, &zones, &reservations, &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::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, .. } => reservations.reserve(Target::Animal(animal), by), Task::Cook { 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); } } } } pub fn find_work( map: &WorldMap, reservations: &Reservations, from: UVec2, wanted: impl Fn(ItemKind) -> bool, ) -> Option { let mut found = None; nearest_tile(map, from, SEARCH_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(work.item) && reservations.is_free(Target::Site(site))).then_some(Task::Work { site, kind: work.kind, progress: 0, }) }); found.is_some() })?; found } pub fn find_storage<'a>( map: &WorldMap, zones: &Zones, reservations: &Reservations, items: impl IntoIterator + Clone, from: UVec2, kind: ItemKind, ) -> Option { nearest_tile(map, from, SEARCH_TILES, |p| { zones.is_stockpile(p) && reservations.is_free(Target::Storage(p)) && room_for(items.clone().into_iter(), p, kind) > 0 }) } pub fn find_haul( map: &WorldMap, zones: &Zones, reservations: &Reservations, items: &[(Item, TilePos)], from: UVec2, ) -> Option { let loose: HashSet = items .iter() .map(|(_, pos)| pos.0) .filter(|&p| !zones.is_stockpile(p) && reservations.is_free(Target::Pickup(p))) .collect(); if loose.is_empty() { return None; } let pickup = nearest_tile(map, from, SEARCH_TILES, |p| loose.contains(&p))?; 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, 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 { if !zones.has_farm() { return None; } let site = nearest_tile(map, from, SEARCH_TILES, |p| { sowable(map, zones, p) && reservations.is_free(Target::Site(p)) })?; Some(Task::Work { site, kind: WorkKind::Sow, progress: 0, }) } pub const HUNT_RADIUS: f32 = 60.0; pub fn find_hunt( map: &WorldMap, reservations: &Reservations, animals: &[(Entity, UVec2, AnimalKind)], from: UVec2, ) -> Option { let mut prey: Vec<_> = animals .iter() .filter(|(e, pos, _)| { reservations.is_free(Target::Animal(*e)) && pos.as_vec2().distance(from.as_vec2()) <= HUNT_RADIUS }) .collect(); prey.sort_by(|a, b| { a.1.as_vec2() .distance_squared(from.as_vec2()) .total_cmp(&b.1.as_vec2().distance_squared(from.as_vec2())) }); prey.into_iter() .find(|(_, pos, _)| find_path_adjacent(map, from, *pos).is_some()) .map(|&(animal, _, kind)| Task::Hunt { 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, } impl BuildContext<'_> { fn buildable(&self, tile: UVec2) -> bool { can_zone(self.map, tile.as_ivec2()) && !self.zones.is_stockpile(tile) && !self.zones.is_farm(tile) && self.map.plant(tile.as_ivec2()).is_none() && !self.structures.iter().any(|(pos, ..)| *pos == tile) && !self.planned.iter().any(|(pos, _)| *pos == tile) && self.blueprints.get(tile).is_none() && !self.items.iter().any(|(_, pos)| pos.0 == tile) && 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 free = |pos: UVec2| self.reservations.is_free(Target::Pickup(pos)); let available: u32 = self .items .iter() .filter(|(item, pos)| item.kind == kind && free(pos.0)) .map(|(item, _)| item.count) .sum(); if available < amount { return None; } nearest_tile(self.map, from, SEARCH_TILES, |p| { free(p) && self .items .iter() .any(|(item, pos)| pos.0 == p && item.kind == kind) }) } fn build_task(&self, from: UVec2, site: UVec2, kind: BuildKind) -> Option { let pickup = self.material_source(from, kind.cost())?; Some(Task::Build { site, kind, pickup: Some(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 raw_food_source(ctx: &BuildContext, from: UVec2) -> Option { nearest_tile(ctx.map, from, SEARCH_TILES, |p| { ctx.reservations.is_free(Target::Pickup(p)) && ctx.items.iter().any(|(item, pos)| { pos.0 == p && item.kind.is_raw_food() && item.count >= COOK_INPUT }) }) } pub fn find_cooking(ctx: &BuildContext, from: UVec2) -> Option { let station = ctx .structures .iter() .filter(|&&(pos, kind, fuel)| { kind == StructureKind::Campfire && fuel >= COOK_MIN_FUEL && ctx.reservations.is_free(Target::Station(pos)) }) .map(|&(pos, ..)| pos) .min_by(|a, b| { a.as_vec2() .distance_squared(from.as_vec2()) .total_cmp(&b.as_vec2().distance_squared(from.as_vec2())) })?; let pickup = raw_food_source(ctx, from)?; Some(Task::Cook { pickup: Some(pickup), station, progress: 0, }) } pub fn find_lighting(ctx: &BuildContext, from: UVec2) -> Option { let home = ctx.zones.home(from); let (kind, site) = if ctx.campfire_needed(home) { (StructureKind::Campfire, ctx.site_near(home, HOME_RADIUS)?) } 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 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 found = None; nearest_tile(ctx.map, from, SEARCH_TILES, |p| { let Some(kind) = ctx.blueprints.get(p) else { return false; }; if !ctx.reservations.is_free(Target::Site(p)) { return false; } found = ctx.build_task(from, p, kind); 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