src/jobs/mod.rs 48 K · 1416 lines · raw · history

1 use std::{
2 collections::{HashMap, HashSet},
3 ops::Range,
4 time::Duration,
5 };
6
7 use bevy::prelude::*;
8
9 use crate::{
10 animals::{Animal, AnimalKind},
11 building::{Blueprints, BuildKind},
12 colonist::{Colonist, Path, repath},
13 food::{EAT_TICKS, Hunger, STARVING, find_food, portion, wants_food},
14 items::{
15 CARRY_CAPACITY, Carrying, Item, ItemAssets, ItemKind, ItemQuery, drop_items, room_for,
16 stock, take_items,
17 },
18 light::{LIT_BRIGHTNESS, LightMap, brightness, dark_soon, light_from},
19 needs::{EXHAUSTED_REST, Rest, SleepContext, done_sleeping, wants_sleep},
20 pathfinding::{find_path, find_path_adjacent, is_adjacent, nearest_tile},
21 rng::SimRng,
22 sim::{SimSystems, SimTick, hour_of_day},
23 structures::{Fuel, Structure, StructureAssets, StructureKind, spawn_structure},
24 world::{Plant, Terrain, TileChanged, TilePos, WorldMap},
25 worldgen::WorldGenerated,
26 zones::{Zones, can_zone, find_stockpile_site, new_stockpile_tiles, plan_expansion},
27 };
28
29 pub struct JobsPlugin;
30
31 impl Plugin for JobsPlugin {
32 fn build(&self, app: &mut App) {
33 app.insert_resource(SimRng::new(0x5EED))
34 .init_resource::<Reservations>()
35 .init_resource::<ColonyGoals>()
36 .add_message::<Order>()
37 .add_message::<TileChanged>()
38 .add_systems(
39 Update,
40 (
41 clear_reservations.run_if(on_message::<WorldGenerated>),
42 handle_orders,
43 ),
44 )
45 .add_systems(FixedUpdate, choose_jobs.in_set(SimSystems::Decide))
46 .add_systems(FixedUpdate, run_jobs.in_set(SimSystems::Act));
47 }
48 }
49
50 pub const EXPAND_WORK: u32 = 240;
51 pub const CAMPFIRE_MIN_FUEL: u32 = 3 * crate::sim::TICKS_PER_HOUR as u32;
52 pub const HOME_RADIUS: f32 = 10.0;
53 pub const BED_STOCKPILE_CLEARANCE: i32 = 3;
54 const LIT_WORK_RATE: u32 = 2;
55 const DARK_WORK_RATE: u32 = 1;
56 pub const EXPANSION_TILES: usize = 8;
57 pub const MIN_EMPTY_STOCKPILE_TILES: usize = 3;
58 const SEARCH_TILES: usize = 15000;
59 const WANDER_RADIUS: i32 = 8;
60 const IDLE_SECS: Range<f32> = 1.0..4.0;
61 const IDLE_AFTER_ORDER_SECS: f32 = 10.0;
62 const WORK_PAUSE_SECS: Range<f32> = 0.2..0.8;
63 const NEIGHBORS: [IVec2; 8] = [
64 IVec2::new(1, 0),
65 IVec2::new(-1, 0),
66 IVec2::new(0, 1),
67 IVec2::new(0, -1),
68 IVec2::new(1, 1),
69 IVec2::new(1, -1),
70 IVec2::new(-1, 1),
71 IVec2::new(-1, -1),
72 ];
73
74 #[derive(Component, Default, Debug, Clone, PartialEq)]
75 pub struct CurrentJob(pub Option<Job>);
76
77 #[derive(Debug, Clone, Copy, PartialEq)]
78 pub struct Job {
79 pub task: Task,
80 pub ordered: bool,
81 }
82
83 #[derive(Debug, Clone, Copy, PartialEq)]
84 pub enum Task {
85 MoveTo(UVec2),
86 Work {
87 site: UVec2,
88 kind: WorkKind,
89 progress: u32,
90 },
91 Haul {
92 pickup: Option<UVec2>,
93 to: UVec2,
94 },
95 ExpandStockpile {
96 site: UVec2,
97 progress: u32,
98 },
99 Build {
100 site: UVec2,
101 kind: BuildKind,
102 pickup: Option<UVec2>,
103 progress: u32,
104 },
105 Sleep {
106 spot: UVec2,
107 },
108 Eat {
109 pickup: Option<UVec2>,
110 progress: u32,
111 },
112 Hunt {
113 animal: Entity,
114 kind: AnimalKind,
115 progress: u32,
116 },
117 Cook {
118 pickup: Option<UVec2>,
119 station: UVec2,
120 progress: u32,
121 },
122 }
123
124 pub fn work_rate(light: &LightMap, tile: UVec2) -> u32 {
125 if light.is_lit(tile) {
126 LIT_WORK_RATE
127 } else {
128 DARK_WORK_RATE
129 }
130 }
131
132 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
133 pub enum WorkKind {
134 Chop,
135 Mine,
136 Harvest,
137 Sow,
138 }
139
140 impl WorkKind {
141 pub fn work(self) -> u32 {
142 match self {
143 WorkKind::Chop => 480,
144 WorkKind::Mine => 720,
145 WorkKind::Harvest => 240,
146 WorkKind::Sow => 160,
147 }
148 }
149
150 fn complete(self, map: &mut WorldMap, site: UVec2) {
151 match self {
152 WorkKind::Chop => map.set_plant(site, None),
153 WorkKind::Mine => map.set(site, Terrain::Gravel),
154 WorkKind::Harvest => {
155 if map.plant(site.as_ivec2()) == Some(Plant::Crop) {
156 map.set_plant(site, None);
157 }
158 map.set_growth(site, 0.0);
159 }
160 WorkKind::Sow => {
161 map.set_plant(site, Some(Plant::Crop));
162 map.set_growth(site, 0.0);
163 }
164 }
165 }
166 }
167
168 pub struct SiteWork {
169 pub kind: WorkKind,
170 pub item: ItemKind,
171 pub count: u32,
172 }
173
174 pub fn site_work(map: &WorldMap, site: UVec2) -> Option<SiteWork> {
175 let p = site.as_ivec2();
176 let harvest = |item, count| SiteWork {
177 kind: WorkKind::Harvest,
178 item,
179 count,
180 };
181 match map.plant(p) {
182 Some(Plant::Tree) => {
183 return Some(SiteWork {
184 kind: WorkKind::Chop,
185 item: ItemKind::Wood,
186 count: 10,
187 });
188 }
189 Some(Plant::BerryBush) if map.ripe_plant(p).is_some() => {
190 return Some(harvest(ItemKind::Berries, 8));
191 }
192 Some(Plant::Crop) if map.ripe_plant(p).is_some() => {
193 return Some(harvest(ItemKind::Vegetables, 10));
194 }
195 _ => {}
196 }
197 let (item, count) = match map.get(p)? {
198 Terrain::Stone => (ItemKind::Stone, 10),
199 Terrain::IronOre => (ItemKind::Iron, 10),
200 Terrain::GoldOre => (ItemKind::Gold, 5),
201 _ => return None,
202 };
203 Some(SiteWork {
204 kind: WorkKind::Mine,
205 item,
206 count,
207 })
208 }
209
210 #[derive(Component, Debug)]
211 pub struct IdleTimer(Timer);
212
213 impl Default for IdleTimer {
214 fn default() -> Self {
215 Self(Timer::from_seconds(IDLE_SECS.start, TimerMode::Once))
216 }
217 }
218
219 impl IdleTimer {
220 fn restart(&mut self, secs: f32) {
221 self.0.set_duration(Duration::from_secs_f32(secs));
222 self.0.reset();
223 }
224 }
225
226 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
227 pub enum Target {
228 Site(UVec2),
229 Pickup(UVec2),
230 Storage(UVec2),
231 StockpileExpansion,
232 SleepSpot(UVec2),
233 Animal(Entity),
234 Station(UVec2),
235 }
236
237 #[derive(Resource, Default, Debug)]
238 pub struct Reservations(HashMap<Target, Entity>);
239
240 impl Reservations {
241 pub fn reserve(&mut self, target: Target, by: Entity) {
242 self.0.insert(target, by);
243 }
244
245 pub fn holder(&self, target: Target) -> Option<Entity> {
246 self.0.get(&target).copied()
247 }
248
249 pub fn is_free(&self, target: Target) -> bool {
250 self.holder(target).is_none()
251 }
252
253 pub fn release(&mut self, target: Target) {
254 self.0.remove(&target);
255 }
256
257 pub fn release_all(&mut self, by: Entity) {
258 self.0.retain(|_, holder| *holder != by);
259 }
260 }
261
262 #[derive(Resource, Debug, Clone)]
263 pub struct ColonyGoals(pub HashMap<ItemKind, u32>);
264
265 impl Default for ColonyGoals {
266 fn default() -> Self {
267 Self(HashMap::from([
268 (ItemKind::Wood, 50),
269 (ItemKind::Stone, 40),
270 (ItemKind::Iron, 20),
271 (ItemKind::Gold, 10),
272 (ItemKind::Berries, 30),
273 (ItemKind::Meat, 30),
274 (ItemKind::Vegetables, 30),
275 (ItemKind::Meal, 10),
276 ]))
277 }
278 }
279
280 impl ColonyGoals {
281 pub fn goal(&self, kind: ItemKind) -> u32 {
282 self.0.get(&kind).copied().unwrap_or(0)
283 }
284
285 pub fn wanted(&self, kind: ItemKind, in_stock: u32, on_the_way: u32) -> bool {
286 in_stock + on_the_way < self.goal(kind)
287 }
288 }
289
290 #[derive(Message, Debug, Clone, Copy)]
291 pub struct Order {
292 pub colonist: Entity,
293 pub tile: UVec2,
294 }
295
296 fn clear_reservations(mut reservations: ResMut<Reservations>) {
297 reservations.0.clear();
298 }
299
300 fn handle_orders(
301 mut orders: MessageReader<Order>,
302 map: Res<WorldMap>,
303 zones: Res<Zones>,
304 items: Query<(&Item, &TilePos)>,
305 animals: Query<(Entity, &TilePos, &Animal), Without<Colonist>>,
306 mut reservations: ResMut<Reservations>,
307 mut colonists: Query<(&TilePos, &mut Path, &mut CurrentJob), With<Colonist>>,
308 ) {
309 for order in orders.read() {
310 let Ok((tile, mut path, mut job)) = colonists.get_mut(order.colonist) else {
311 continue;
312 };
313 let loose_item = items
314 .iter()
315 .find(|(_, pos)| pos.0 == order.tile && !zones.is_stockpile(pos.0));
316 let animal = animals.iter().find(|(_, pos, _)| pos.0 == order.tile);
317 let task = if let Some((animal, _, a)) = animal {
318 Some(Task::Hunt {
319 animal,
320 kind: a.kind,
321 progress: 0,
322 })
323 } else if let Some(work) = site_work(&map, order.tile) {
324 Some(Task::Work {
325 site: order.tile,
326 kind: work.kind,
327 progress: 0,
328 })
329 } else if let Some((item, _)) = loose_item {
330 find_storage(&map, &zones, &reservations, &items, order.tile, item.kind).map(|to| {
331 Task::Haul {
332 pickup: Some(order.tile),
333 to,
334 }
335 })
336 } else {
337 None
338 };
339 if let Some(task) = task {
340 reservations.release_all(order.colonist);
341 reserve_task(&mut reservations, task, order.colonist);
342 path.0.truncate(1);
343 job.0 = Some(Job {
344 task,
345 ordered: true,
346 });
347 } else if let Some(new_path) = repath(&map, tile.0, &path.0, order.tile) {
348 reservations.release_all(order.colonist);
349 path.0 = new_path;
350 job.0 = Some(Job {
351 task: Task::MoveTo(order.tile),
352 ordered: true,
353 });
354 }
355 }
356 }
357
358 fn reserve_task(reservations: &mut Reservations, task: Task, by: Entity) {
359 match task {
360 Task::MoveTo(_) => {}
361 Task::Work { site, .. } => reservations.reserve(Target::Site(site), by),
362 Task::Haul { pickup, to } => {
363 if let Some(pickup) = pickup {
364 reservations.reserve(Target::Pickup(pickup), by);
365 }
366 reservations.reserve(Target::Storage(to), by);
367 }
368 Task::ExpandStockpile { .. } => reservations.reserve(Target::StockpileExpansion, by),
369 Task::Sleep { spot } => reservations.reserve(Target::SleepSpot(spot), by),
370 Task::Eat { pickup, .. } => {
371 if let Some(pickup) = pickup {
372 reservations.reserve(Target::Pickup(pickup), by);
373 }
374 }
375 Task::Hunt { animal, .. } => reservations.reserve(Target::Animal(animal), by),
376 Task::Cook {
377 pickup, station, ..
378 } => {
379 reservations.reserve(Target::Station(station), by);
380 if let Some(pickup) = pickup {
381 reservations.reserve(Target::Pickup(pickup), by);
382 }
383 }
384 Task::Build { site, pickup, .. } => {
385 reservations.reserve(Target::Site(site), by);
386 if let Some(pickup) = pickup {
387 reservations.reserve(Target::Pickup(pickup), by);
388 }
389 }
390 }
391 }
392
393 pub fn find_work(
394 map: &WorldMap,
395 reservations: &Reservations,
396 from: UVec2,
397 wanted: impl Fn(ItemKind) -> bool,
398 ) -> Option<Task> {
399 let mut found = None;
400 nearest_tile(map, from, SEARCH_TILES, |stand| {
401 found = NEIGHBORS.iter().find_map(|&d| {
402 let site = stand.as_ivec2() + d;
403 if site.x < 0 || site.y < 0 {
404 return None;
405 }
406 let site = site.as_uvec2();
407 let work = site_work(map, site)?;
408 (wanted(work.item) && reservations.is_free(Target::Site(site))).then_some(Task::Work {
409 site,
410 kind: work.kind,
411 progress: 0,
412 })
413 });
414 found.is_some()
415 })?;
416 found
417 }
418
419 pub fn find_storage<'a>(
420 map: &WorldMap,
421 zones: &Zones,
422 reservations: &Reservations,
423 items: impl IntoIterator<Item = (&'a Item, &'a TilePos)> + Clone,
424 from: UVec2,
425 kind: ItemKind,
426 ) -> Option<UVec2> {
427 nearest_tile(map, from, SEARCH_TILES, |p| {
428 zones.is_stockpile(p)
429 && reservations.is_free(Target::Storage(p))
430 && room_for(items.clone().into_iter(), p, kind) > 0
431 })
432 }
433
434 pub fn find_haul(
435 map: &WorldMap,
436 zones: &Zones,
437 reservations: &Reservations,
438 items: &[(Item, TilePos)],
439 from: UVec2,
440 ) -> Option<Task> {
441 let loose: HashSet<UVec2> = items
442 .iter()
443 .map(|(_, pos)| pos.0)
444 .filter(|&p| !zones.is_stockpile(p) && reservations.is_free(Target::Pickup(p)))
445 .collect();
446 if loose.is_empty() {
447 return None;
448 }
449 let pickup = nearest_tile(map, from, SEARCH_TILES, |p| loose.contains(&p))?;
450 let (item, _) = items.iter().find(|(_, pos)| pos.0 == pickup)?;
451 let refs = items.iter().map(|(i, p)| (i, p));
452 let to = find_storage(map, zones, reservations, refs, pickup, item.kind)?;
453 Some(Task::Haul {
454 pickup: Some(pickup),
455 to,
456 })
457 }
458
459 pub fn sowable(map: &WorldMap, zones: &Zones, site: UVec2) -> bool {
460 zones.is_farm(site) && map.plant(site.as_ivec2()).is_none()
461 }
462
463 pub fn find_sowing(
464 map: &WorldMap,
465 zones: &Zones,
466 reservations: &Reservations,
467 from: UVec2,
468 ) -> Option<Task> {
469 if !zones.has_farm() {
470 return None;
471 }
472 let site = nearest_tile(map, from, SEARCH_TILES, |p| {
473 sowable(map, zones, p) && reservations.is_free(Target::Site(p))
474 })?;
475 Some(Task::Work {
476 site,
477 kind: WorkKind::Sow,
478 progress: 0,
479 })
480 }
481
482 pub const HUNT_RADIUS: f32 = 60.0;
483
484 pub fn find_hunt(
485 map: &WorldMap,
486 reservations: &Reservations,
487 animals: &[(Entity, UVec2, AnimalKind)],
488 from: UVec2,
489 ) -> Option<Task> {
490 let mut prey: Vec<_> = animals
491 .iter()
492 .filter(|(e, pos, _)| {
493 reservations.is_free(Target::Animal(*e))
494 && pos.as_vec2().distance(from.as_vec2()) <= HUNT_RADIUS
495 })
496 .collect();
497 prey.sort_by(|a, b| {
498 a.1.as_vec2()
499 .distance_squared(from.as_vec2())
500 .total_cmp(&b.1.as_vec2().distance_squared(from.as_vec2()))
501 });
502 prey.into_iter()
503 .find(|(_, pos, _)| find_path_adjacent(map, from, *pos).is_some())
504 .map(|&(animal, _, kind)| Task::Hunt {
505 animal,
506 kind,
507 progress: 0,
508 })
509 }
510
511 pub fn stockpile_too_full(zones: &Zones, items: &[(Item, TilePos)]) -> bool {
512 let occupied: HashSet<UVec2> = items.iter().map(|(_, pos)| pos.0).collect();
513 zones.has_stockpile() && zones.empty_stockpile_tiles(&occupied) < MIN_EMPTY_STOCKPILE_TILES
514 }
515
516 pub fn find_expansion(
517 map: &WorldMap,
518 zones: &Zones,
519 reservations: &Reservations,
520 items: &[(Item, TilePos)],
521 blueprints: &Blueprints,
522 ) -> Option<Task> {
523 if !reservations.is_free(Target::StockpileExpansion) {
524 return None;
525 }
526 let site = if zones.has_stockpile() {
527 if !stockpile_too_full(zones, items) {
528 return None;
529 }
530 *plan_expansion(map, zones, &blueprints.tiles(), EXPANSION_TILES).first()?
531 } else {
532 find_stockpile_site(map, central_item_tile(items)?)?
533 };
534 Some(Task::ExpandStockpile { site, progress: 0 })
535 }
536
537 fn central_item_tile(items: &[(Item, TilePos)]) -> Option<UVec2> {
538 let tiles: Vec<Vec2> = items.iter().map(|(_, pos)| pos.0.as_vec2()).collect();
539 let centroid = tiles.iter().sum::<Vec2>() / tiles.len().max(1) as f32;
540 items.iter().map(|(_, pos)| pos.0).min_by(|a, b| {
541 a.as_vec2()
542 .distance_squared(centroid)
543 .total_cmp(&b.as_vec2().distance_squared(centroid))
544 })
545 }
546
547 pub struct BuildContext<'a> {
548 pub map: &'a WorldMap,
549 pub zones: &'a Zones,
550 pub reservations: &'a Reservations,
551 pub items: &'a [(Item, TilePos)],
552 pub structures: &'a [(UVec2, StructureKind, u32)],
553 pub planned: &'a [(UVec2, StructureKind)],
554 pub blueprints: &'a Blueprints,
555 }
556
557 impl BuildContext<'_> {
558 fn buildable(&self, tile: UVec2) -> bool {
559 can_zone(self.map, tile.as_ivec2())
560 && !self.zones.is_stockpile(tile)
561 && !self.zones.is_farm(tile)
562 && self.map.plant(tile.as_ivec2()).is_none()
563 && !self.structures.iter().any(|(pos, ..)| *pos == tile)
564 && !self.planned.iter().any(|(pos, _)| *pos == tile)
565 && self.blueprints.get(tile).is_none()
566 && !self.items.iter().any(|(_, pos)| pos.0 == tile)
567 && self.reservations.is_free(Target::Site(tile))
568 }
569
570 fn lights(&self) -> impl Iterator<Item = (UVec2, StructureKind)> + Clone + '_ {
571 self.structures
572 .iter()
573 .map(|&(pos, kind, _)| (pos, kind))
574 .chain(self.planned.iter().copied())
575 }
576
577 fn campfire_needed(&self, home: UVec2) -> bool {
578 let burning = self.structures.iter().any(|&(pos, kind, fuel)| {
579 kind == StructureKind::Campfire
580 && fuel >= CAMPFIRE_MIN_FUEL
581 && pos.as_vec2().distance(home.as_vec2()) <= HOME_RADIUS
582 });
583 let planned = self
584 .planned
585 .iter()
586 .any(|(_, kind)| *kind == StructureKind::Campfire);
587 !burning && !planned
588 }
589
590 fn darkest_stockpile_tile(&self, home: UVec2) -> Option<UVec2> {
591 self.zones
592 .stockpile_tiles()
593 .filter(|&t| brightness(light_from(t, self.lights())) < LIT_BRIGHTNESS)
594 .min_by(|a, b| {
595 a.as_vec2()
596 .distance_squared(home.as_vec2())
597 .total_cmp(&b.as_vec2().distance_squared(home.as_vec2()))
598 .then((a.y, a.x).cmp(&(b.y, b.x)))
599 })
600 }
601
602 fn site_near(&self, target: UVec2, max_distance: f32) -> Option<UVec2> {
603 self.site_near_where(target, max_distance, |_| true)
604 }
605
606 fn site_near_where(
607 &self,
608 target: UVec2,
609 max_distance: f32,
610 extra: impl Fn(UVec2) -> bool,
611 ) -> Option<UVec2> {
612 nearest_tile(self.map, target, SEARCH_TILES, |p| {
613 self.buildable(p) && extra(p)
614 })
615 .filter(|site| site.as_vec2().distance(target.as_vec2()) <= max_distance)
616 }
617
618 fn clear_of_stockpile(&self, tile: UVec2) -> bool {
619 self.zones
620 .stockpile_tiles()
621 .chain(self.zones.farm_tiles())
622 .all(|s| {
623 (s.as_ivec2() - tile.as_ivec2()).abs().max_element() >= BED_STOCKPILE_CLEARANCE
624 })
625 }
626
627 fn count(&self, kind: StructureKind) -> usize {
628 let built = self.structures.iter().filter(|s| s.1 == kind).count();
629 built + self.planned.iter().filter(|p| p.1 == kind).count()
630 }
631
632 fn bed_anchor(&self, home: UVec2) -> UVec2 {
633 let positions = |kind: StructureKind| {
634 let built = self.structures.iter().map(|s| (s.0, s.1));
635 built
636 .chain(self.planned.iter().copied())
637 .filter(move |s| s.1 == kind)
638 .map(|s| s.0)
639 };
640 let nearest = |kind: StructureKind| {
641 positions(kind).min_by(|a, b| {
642 a.as_vec2()
643 .distance_squared(home.as_vec2())
644 .total_cmp(&b.as_vec2().distance_squared(home.as_vec2()))
645 })
646 };
647 nearest(StructureKind::Bed)
648 .or_else(|| nearest(StructureKind::Campfire))
649 .unwrap_or(home)
650 }
651
652 fn material_source(&self, from: UVec2, (kind, amount): (ItemKind, u32)) -> Option<UVec2> {
653 nearest_tile(self.map, from, SEARCH_TILES, |p| {
654 self.reservations.is_free(Target::Pickup(p))
655 && self
656 .items
657 .iter()
658 .any(|(item, pos)| pos.0 == p && item.kind == kind && item.count >= amount)
659 })
660 }
661
662 fn build_task(&self, from: UVec2, site: UVec2, kind: BuildKind) -> Option<Task> {
663 let pickup = self.material_source(from, kind.cost())?;
664 Some(Task::Build {
665 site,
666 kind,
667 pickup: Some(pickup),
668 progress: 0,
669 })
670 }
671 }
672
673 pub const COOK_INPUT: u32 = 6;
674 pub const COOK_WORK: u32 = 300;
675 pub const COOK_MIN_FUEL: u32 = crate::sim::TICKS_PER_HOUR as u32;
676
677 pub fn find_campfire(ctx: &BuildContext, from: UVec2) -> Option<Task> {
678 let home = ctx.zones.home(from);
679 if !ctx.campfire_needed(home) {
680 return None;
681 }
682 let site = ctx.site_near(home, HOME_RADIUS)?;
683 ctx.build_task(from, site, BuildKind::Structure(StructureKind::Campfire))
684 }
685
686 pub fn raw_food_source(ctx: &BuildContext, from: UVec2) -> Option<UVec2> {
687 nearest_tile(ctx.map, from, SEARCH_TILES, |p| {
688 ctx.reservations.is_free(Target::Pickup(p))
689 && ctx.items.iter().any(|(item, pos)| {
690 pos.0 == p && item.kind.is_raw_food() && item.count >= COOK_INPUT
691 })
692 })
693 }
694
695 pub fn find_cooking(ctx: &BuildContext, from: UVec2) -> Option<Task> {
696 let station = ctx
697 .structures
698 .iter()
699 .filter(|&&(pos, kind, fuel)| {
700 kind == StructureKind::Campfire
701 && fuel >= COOK_MIN_FUEL
702 && ctx.reservations.is_free(Target::Station(pos))
703 })
704 .map(|&(pos, ..)| pos)
705 .min_by(|a, b| {
706 a.as_vec2()
707 .distance_squared(from.as_vec2())
708 .total_cmp(&b.as_vec2().distance_squared(from.as_vec2()))
709 })?;
710 let pickup = raw_food_source(ctx, from)?;
711 Some(Task::Cook {
712 pickup: Some(pickup),
713 station,
714 progress: 0,
715 })
716 }
717
718 pub fn find_lighting(ctx: &BuildContext, from: UVec2) -> Option<Task> {
719 let home = ctx.zones.home(from);
720 let (kind, site) = if ctx.campfire_needed(home) {
721 (StructureKind::Campfire, ctx.site_near(home, HOME_RADIUS)?)
722 } else {
723 let dark = ctx.darkest_stockpile_tile(home)?;
724 let reach = StructureKind::Torch.radius() * 0.6;
725 (StructureKind::Torch, ctx.site_near(dark, reach)?)
726 };
727 ctx.build_task(from, site, BuildKind::Structure(kind))
728 }
729
730 pub fn find_bed_site(ctx: &BuildContext, colonists: usize, from: UVec2) -> Option<Task> {
731 if ctx.count(StructureKind::Bed) >= colonists {
732 return None;
733 }
734 let anchor = ctx.bed_anchor(ctx.zones.home(from));
735 let site = ctx.site_near_where(anchor, HOME_RADIUS * 1.5, |p| ctx.clear_of_stockpile(p))?;
736 ctx.build_task(from, site, BuildKind::Structure(StructureKind::Bed))
737 }
738
739 pub fn find_blueprint_work(ctx: &BuildContext, from: UVec2) -> Option<Task> {
740 let mut found = None;
741 nearest_tile(ctx.map, from, SEARCH_TILES, |p| {
742 let Some(kind) = ctx.blueprints.get(p) else {
743 return false;
744 };
745 if !ctx.reservations.is_free(Target::Site(p)) {
746 return false;
747 }
748 found = ctx.build_task(from, p, kind);
749 found.is_some()
750 })?;
751 found
752 }
753
754 fn on_the_way(map: &WorldMap, jobs: impl Iterator<Item = Option<Job>>) -> HashMap<ItemKind, u32> {
755 let mut totals = HashMap::new();
756 for job in jobs.flatten() {
757 if let Task::Work { site, .. } = job.task
758 && let Some(work) = site_work(map, site)
759 {
760 *totals.entry(work.item).or_insert(0) += work.count;
761 }
762 }
763 totals
764 }
765
766 #[allow(clippy::too_many_arguments)]
767 fn choose_jobs(
768 mut commands: Commands,
769 time: Res<Time>,
770 map: Res<WorldMap>,
771 zones: Res<Zones>,
772 goals: Res<ColonyGoals>,
773 item_assets: Res<ItemAssets>,
774 tick: Res<SimTick>,
775 blueprints: Res<Blueprints>,
776 mut rng: ResMut<SimRng>,
777 mut reservations: ResMut<Reservations>,
778 mut items: ItemQuery,
779 structures: Query<(&TilePos, &Structure, Option<&Fuel>), Without<Colonist>>,
780 animals: Query<(Entity, &TilePos, &Animal), Without<Colonist>>,
781 mut colonists: Query<
782 (
783 Entity,
784 &TilePos,
785 &mut Path,
786 &mut CurrentJob,
787 &mut IdleTimer,
788 &mut Carrying,
789 &Rest,
790 &Hunger,
791 ),
792 With<Colonist>,
793 >,
794 ) {
795 let wildlife: Vec<(Entity, UVec2, AnimalKind)> = animals
796 .iter()
797 .map(|(e, pos, a)| (e, pos.0, a.kind))
798 .collect();
799 let colonist_count = colonists.iter().count();
800 let structures_list: Vec<(UVec2, StructureKind, u32)> = structures
801 .iter()
802 .map(|(pos, s, fuel)| (pos.0, s.kind, fuel.map_or(u32::MAX, |f| f.0)))
803 .collect();
804 let mut planned: Vec<(UVec2, StructureKind)> = colonists
805 .iter()
806 .filter_map(|c| match c.3.0?.task {
807 Task::Build {
808 site,
809 kind: BuildKind::Structure(kind),
810 ..
811 } => Some((site, kind)),
812 _ => None,
813 })
814 .collect();
815 let dark = dark_soon(*tick);
816 let carried: Vec<Item> = colonists.iter().filter_map(|c| c.5.0).collect();
817 let stocks: HashMap<ItemKind, u32> = ItemKind::ALL
818 .iter()
819 .map(|&kind| {
820 let all = items.iter().map(|(_, item, _)| item).chain(carried.iter());
821 (kind, stock(all, kind))
822 })
823 .collect();
824 let mut pending = on_the_way(&map, colonists.iter().map(|c| c.3.0));
825 for job in colonists.iter().filter_map(|c| c.3.0) {
826 match job.task {
827 Task::Hunt { kind, .. } => *pending.entry(ItemKind::Meat).or_insert(0) += kind.meat(),
828 Task::Cook { .. } => *pending.entry(ItemKind::Meal).or_insert(0) += 1,
829 _ => {}
830 }
831 }
832 let hour = hour_of_day(*tick);
833 for (entity, tile, mut path, mut job, mut idle, mut carrying, rest, hunger) in &mut colonists {
834 let exhausted = rest.0 < EXHAUSTED_REST;
835 let starving = hunger.0 < STARVING;
836 let urgent = exhausted || starving;
837 if urgent
838 && job.0.is_some_and(|j| {
839 !j.ordered && !matches!(j.task, Task::Sleep { .. } | Task::Eat { .. })
840 })
841 {
842 reservations.release_all(entity);
843 job.0 = None;
844 path.0.truncate(1);
845 }
846 let rested = idle.0.tick(time.delta()).is_finished();
847 if job.0.is_some() || !path.0.is_empty() || !(rested || urgent) {
848 continue;
849 }
850 let snapshot: Vec<(Item, TilePos)> =
851 items.iter().map(|(_, item, pos)| (*item, *pos)).collect();
852 let refs = snapshot.iter().map(|(i, p)| (i, p));
853 let wanted = |kind: ItemKind| {
854 goals.wanted(
855 kind,
856 stocks[&kind],
857 pending.get(&kind).copied().unwrap_or(0),
858 )
859 };
860 let sleep = SleepContext {
861 map: &map,
862 zones: &zones,
863 reservations: &reservations,
864 items: &snapshot,
865 structures: &structures_list,
866 blueprints: &blueprints,
867 };
868 let food = wants_food(hunger.0)
869 .then(|| find_food(&map, &reservations, &snapshot, tile.0))
870 .flatten();
871 let task = if exhausted {
872 Some(Task::Sleep {
873 spot: sleep.spot(tile.0),
874 })
875 } else if let Some(pickup) = food {
876 if let Some(load) = carrying.0.take() {
877 drop_items(
878 &mut commands,
879 &mut items,
880 &item_assets,
881 tile.0,
882 load.kind,
883 load.count,
884 );
885 }
886 Some(Task::Eat {
887 pickup: Some(pickup),
888 progress: 0,
889 })
890 } else if wants_sleep(rest.0, hour) {
891 Some(Task::Sleep {
892 spot: sleep.spot(tile.0),
893 })
894 } else if let Some(load) = carrying.0 {
895 let storage = find_storage(&map, &zones, &reservations, refs, tile.0, load.kind);
896 if storage.is_none() {
897 drop_items(
898 &mut commands,
899 &mut items,
900 &item_assets,
901 tile.0,
902 load.kind,
903 load.count,
904 );
905 carrying.0 = None;
906 }
907 storage.map(|to| Task::Haul { pickup: None, to })
908 } else {
909 let build = BuildContext {
910 map: &map,
911 zones: &zones,
912 reservations: &reservations,
913 items: &snapshot,
914 structures: &structures_list,
915 planned: &planned,
916 blueprints: &blueprints,
917 };
918 find_expansion(&map, &zones, &reservations, &snapshot, &blueprints)
919 .or_else(|| dark.then(|| find_lighting(&build, tile.0)).flatten())
920 .or_else(|| {
921 let cooking =
922 wanted(ItemKind::Meal) && raw_food_source(&build, tile.0).is_some();
923 cooking
924 .then(|| {
925 find_cooking(&build, tile.0).or_else(|| find_campfire(&build, tile.0))
926 })
927 .flatten()
928 })
929 .or_else(|| find_work(&map, &reservations, tile.0, wanted))
930 .or_else(|| {
931 wanted(ItemKind::Meat)
932 .then(|| find_hunt(&map, &reservations, &wildlife, tile.0))
933 .flatten()
934 })
935 .or_else(|| find_sowing(&map, &zones, &reservations, tile.0))
936 .or_else(|| find_haul(&map, &zones, &reservations, &snapshot, tile.0))
937 .or_else(|| find_bed_site(&build, colonist_count, tile.0))
938 .or_else(|| find_blueprint_work(&build, tile.0))
939 };
940 if let Some(task) = task {
941 if let Task::Work { site, .. } = task
942 && let Some(work) = site_work(&map, site)
943 {
944 *pending.entry(work.item).or_insert(0) += work.count;
945 }
946 match task {
947 Task::Hunt { kind, .. } => {
948 *pending.entry(ItemKind::Meat).or_insert(0) += kind.meat()
949 }
950 Task::Cook { .. } => *pending.entry(ItemKind::Meal).or_insert(0) += 1,
951 _ => {}
952 }
953 if let Task::Build {
954 site,
955 kind: BuildKind::Structure(kind),
956 ..
957 } = task
958 {
959 planned.push((site, kind));
960 }
961 reserve_task(&mut reservations, task, entity);
962 job.0 = Some(Job {
963 task,
964 ordered: false,
965 });
966 continue;
967 }
968 let goal = tile.0.as_ivec2()
969 + IVec2::new(
970 rng.range_i32(-WANDER_RADIUS..WANDER_RADIUS + 1),
971 rng.range_i32(-WANDER_RADIUS..WANDER_RADIUS + 1),
972 );
973 if map.in_bounds(goal)
974 && let Some(wander_path) = find_path(&map, tile.0, goal.as_uvec2())
975 {
976 path.0 = wander_path.into();
977 idle.restart(rng.range_f32(IDLE_SECS));
978 }
979 }
980 }
981
982 enum Step {
983 Continue,
984 Done,
985 }
986
987 fn walk_to(map: &WorldMap, from: UVec2, to: UVec2, path: &mut Path) -> Step {
988 match find_path(map, from, to) {
989 Some(p) if !p.is_empty() => {
990 path.0 = p.into();
991 Step::Continue
992 }
993 _ => Step::Done,
994 }
995 }
996
997 #[allow(clippy::too_many_arguments)]
998 fn run_jobs(
999 mut commands: Commands,
1000 mut map: ResMut<WorldMap>,
1001 mut zones: ResMut<Zones>,
1002 mut reservations: ResMut<Reservations>,
1003 mut rng: ResMut<SimRng>,
1004 item_assets: Res<ItemAssets>,
1005 structure_assets: Res<StructureAssets>,
1006 light: Res<LightMap>,
1007 tick: Res<SimTick>,
1008 mut blueprints: ResMut<Blueprints>,
1009 mut items: ItemQuery,
1010 structures: Query<(&TilePos, &Structure), Without<Colonist>>,
1011 animals: Query<&TilePos, (With<Animal>, Without<Colonist>)>,
1012 mut changed: MessageWriter<TileChanged>,
1013 mut colonists: Query<
1014 (
1015 Entity,
1016 &TilePos,
1017 &mut Path,
1018 &mut CurrentJob,
1019 &mut IdleTimer,
1020 &mut Carrying,
1021 &Rest,
1022 &mut Hunger,
1023 ),
1024 With<Colonist>,
1025 >,
1026 ) {
1027 let hour = hour_of_day(*tick);
1028 let occupied: Vec<UVec2> = colonists.iter().map(|c| c.1.0).collect();
1029 for (entity, tile, mut path, mut current, mut idle, mut carrying, rest, mut hunger) in
1030 &mut colonists
1031 {
1032 let Some(mut job) = current.0 else {
1033 continue;
1034 };
1035 if !path.0.is_empty() {
1036 continue;
1037 }
1038 let step = match &mut job.task {
1039 Task::MoveTo(_) => Step::Done,
1040 Task::Cook {
1041 pickup,
1042 station,
1043 progress,
1044 } => {
1045 let station = *station;
1046 let fire = structures
1047 .iter()
1048 .any(|(pos, s)| pos.0 == station && s.kind == StructureKind::Campfire);
1049 let ours = reservations.holder(Target::Station(station)) == Some(entity);
1050 if !fire || !ours {
1051 Step::Done
1052 } else if let Some(from) = *pickup {
1053 if reservations.holder(Target::Pickup(from)) != Some(entity) {
1054 Step::Done
1055 } else if tile.0 != from {
1056 walk_to(&map, tile.0, from, &mut path)
1057 } else {
1058 let load = take_items(&mut commands, &mut items, from, COOK_INPUT)
1059 .filter(|load| load.kind.is_raw_food() && load.count > 0);
1060 carrying.0 = load;
1061 reservations.release(Target::Pickup(from));
1062 match load {
1063 Some(load) if load.count >= COOK_INPUT => {
1064 *pickup = None;
1065 Step::Continue
1066 }
1067 _ => Step::Done,
1068 }
1069 }
1070 } else if !carrying
1071 .0
1072 .is_some_and(|load| load.kind.is_raw_food() && load.count >= COOK_INPUT)
1073 {
1074 Step::Done
1075 } else if !is_adjacent(tile.0.as_ivec2(), station.as_ivec2()) {
1076 match find_path_adjacent(&map, tile.0, station) {
1077 Some(p) if !p.is_empty() => {
1078 path.0 = p.into();
1079 Step::Continue
1080 }
1081 _ => Step::Done,
1082 }
1083 } else {
1084 *progress += work_rate(&light, tile.0);
1085 if *progress < COOK_WORK {
1086 Step::Continue
1087 } else {
1088 carrying.0 = carrying.0.and_then(|load| {
1089 (load.count > COOK_INPUT).then_some(Item {
1090 count: load.count - COOK_INPUT,
1091 ..load
1092 })
1093 });
1094 drop_items(
1095 &mut commands,
1096 &mut items,
1097 &item_assets,
1098 tile.0,
1099 ItemKind::Meal,
1100 1,
1101 );
1102 Step::Done
1103 }
1104 }
1105 }
1106 Task::Hunt {
1107 animal,
1108 kind,
1109 progress,
1110 } => match animals.get(*animal) {
1111 Ok(prey) if reservations.holder(Target::Animal(*animal)) == Some(entity) => {
1112 let prey = prey.0;
1113 if prey != tile.0 && !is_adjacent(tile.0.as_ivec2(), prey.as_ivec2()) {
1114 match find_path_adjacent(&map, tile.0, prey) {
1115 Some(p) if !p.is_empty() => {
1116 path.0 = p.into();
1117 Step::Continue
1118 }
1119 _ => Step::Done,
1120 }
1121 } else {
1122 *progress += work_rate(&light, tile.0);
1123 if *progress < kind.hunt_work() {
1124 Step::Continue
1125 } else {
1126 commands.entity(*animal).despawn();
1127 drop_items(
1128 &mut commands,
1129 &mut items,
1130 &item_assets,
1131 prey,
1132 ItemKind::Meat,
1133 kind.meat(),
1134 );
1135 Step::Done
1136 }
1137 }
1138 }
1139 _ => Step::Done,
1140 },
1141 Task::Eat { pickup, progress } => {
1142 if let Some(from) = *pickup {
1143 if reservations.holder(Target::Pickup(from)) != Some(entity) {
1144 Step::Done
1145 } else if tile.0 != from {
1146 walk_to(&map, tile.0, from, &mut path)
1147 } else {
1148 let kind = items
1149 .iter()
1150 .find(|(_, item, pos)| pos.0 == from && item.kind.is_food())
1151 .map(|(_, item, _)| item.kind);
1152 let meal = kind.and_then(|kind| {
1153 take_items(&mut commands, &mut items, from, portion(kind, hunger.0))
1154 });
1155 reservations.release(Target::Pickup(from));
1156 match meal.filter(|m| m.kind.is_food()) {
1157 Some(meal) => {
1158 carrying.0 = Some(meal);
1159 *pickup = None;
1160 Step::Continue
1161 }
1162 None => Step::Done,
1163 }
1164 }
1165 } else if let Some(meal) = carrying.0.filter(|m| m.kind.is_food()) {
1166 *progress += 1;
1167 if *progress < EAT_TICKS {
1168 Step::Continue
1169 } else {
1170 hunger.0 = (hunger.0 + meal.count as f32 * meal.kind.nutrition()).min(1.0);
1171 carrying.0 = None;
1172 Step::Done
1173 }
1174 } else {
1175 Step::Done
1176 }
1177 }
1178 Task::Sleep { spot } => {
1179 if reservations.holder(Target::SleepSpot(*spot)) != Some(entity) {
1180 Step::Done
1181 } else if tile.0 != *spot {
1182 let step = walk_to(&map, tile.0, *spot, &mut path);
1183 if matches!(step, Step::Done) {
1184 reservations.release(Target::SleepSpot(*spot));
1185 *spot = tile.0;
1186 reservations.reserve(Target::SleepSpot(*spot), entity);
1187 }
1188 Step::Continue
1189 } else if done_sleeping(rest.0, hour) {
1190 Step::Done
1191 } else {
1192 Step::Continue
1193 }
1194 }
1195 Task::Work {
1196 site,
1197 kind,
1198 progress,
1199 } => {
1200 let (site, kind) = (*site, *kind);
1201 let work = site_work(&map, site).filter(|w| w.kind == kind);
1202 let valid =
1203 work.is_some() || (kind == WorkKind::Sow && sowable(&map, &zones, site));
1204 let ours = reservations.holder(Target::Site(site)) == Some(entity);
1205 if !valid || !ours {
1206 Step::Done
1207 } else if !is_adjacent(tile.0.as_ivec2(), site.as_ivec2()) {
1208 match find_path_adjacent(&map, tile.0, site) {
1209 Some(p) if !p.is_empty() => {
1210 path.0 = p.into();
1211 Step::Continue
1212 }
1213 _ => Step::Done,
1214 }
1215 } else {
1216 *progress += work_rate(&light, tile.0);
1217 if *progress < kind.work() {
1218 Step::Continue
1219 } else {
1220 kind.complete(&mut map, site);
1221 changed.write(TileChanged(site));
1222 if let Some(work) = work {
1223 drop_items(
1224 &mut commands,
1225 &mut items,
1226 &item_assets,
1227 site,
1228 work.item,
1229 work.count,
1230 );
1231 }
1232 Step::Done
1233 }
1234 }
1235 }
1236 Task::ExpandStockpile { site, progress } => {
1237 if tile.0 != *site {
1238 walk_to(&map, tile.0, *site, &mut path)
1239 } else {
1240 *progress += work_rate(&light, tile.0);
1241 if *progress >= EXPAND_WORK {
1242 if zones.has_stockpile() {
1243 let excluded = blueprints.tiles();
1244 let tiles = plan_expansion(&map, &zones, &excluded, EXPANSION_TILES);
1245 zones.add_tiles(&map, tiles);
1246 } else {
1247 zones.add_tiles(&map, new_stockpile_tiles(*site));
1248 }
1249 Step::Done
1250 } else {
1251 Step::Continue
1252 }
1253 }
1254 }
1255 Task::Build {
1256 site,
1257 kind,
1258 pickup,
1259 progress,
1260 } => {
1261 let (site, kind) = (*site, *kind);
1262 let ours = reservations.holder(Target::Site(site)) == Some(entity);
1263 let planned = match kind {
1264 BuildKind::Structure(
1265 StructureKind::Campfire | StructureKind::Torch | StructureKind::Bed,
1266 ) => true,
1267 _ => blueprints.get(site) == Some(kind),
1268 };
1269 let blocked = map.plant(site.as_ivec2()).is_some()
1270 || (kind != BuildKind::Roof && structures.iter().any(|(pos, _)| pos.0 == site));
1271 let (material, cost) = kind.cost();
1272 if !ours || blocked || !planned {
1273 Step::Done
1274 } else if let Some(from) = *pickup {
1275 if reservations.holder(Target::Pickup(from)) != Some(entity) {
1276 Step::Done
1277 } else if tile.0 != from {
1278 walk_to(&map, tile.0, from, &mut path)
1279 } else {
1280 let load = take_items(&mut commands, &mut items, from, cost)
1281 .filter(|load| load.kind == material && load.count > 0);
1282 carrying.0 = load;
1283 match load {
1284 Some(load) if load.count >= cost => {
1285 reservations.release(Target::Pickup(from));
1286 *pickup = None;
1287 Step::Continue
1288 }
1289 _ => Step::Done,
1290 }
1291 }
1292 } else if !carrying
1293 .0
1294 .is_some_and(|load| load.kind == material && load.count >= cost)
1295 {
1296 Step::Done
1297 } else if !is_adjacent(tile.0.as_ivec2(), site.as_ivec2()) {
1298 match find_path_adjacent(&map, tile.0, site) {
1299 Some(p) if !p.is_empty() => {
1300 path.0 = p.into();
1301 Step::Continue
1302 }
1303 _ => Step::Done,
1304 }
1305 } else {
1306 *progress = (*progress + work_rate(&light, tile.0)).min(kind.work());
1307 let someone_in_the_way = kind.blocks_tile() && occupied.contains(&site);
1308 if *progress < kind.work() || someone_in_the_way {
1309 Step::Continue
1310 } else {
1311 carrying.0 = carrying.0.and_then(|load| {
1312 (load.count > cost).then_some(Item {
1313 count: load.count - cost,
1314 ..load
1315 })
1316 });
1317 match kind {
1318 BuildKind::Structure(structure) => {
1319 spawn_structure(&mut commands, &structure_assets, site, structure);
1320 if kind.blocks_tile() {
1321 map.set_wall(site, true);
1322 changed.write(TileChanged(site));
1323 }
1324 }
1325 BuildKind::Roof => {
1326 map.set_roof(site, true);
1327 changed.write(TileChanged(site));
1328 }
1329 }
1330 blueprints.0.remove(&site);
1331 Step::Done
1332 }
1333 }
1334 }
1335 Task::Haul { pickup, to } => {
1336 let to = *to;
1337 let destination_valid = zones.is_stockpile(to)
1338 && reservations.holder(Target::Storage(to)) == Some(entity);
1339 if !destination_valid {
1340 Step::Done
1341 } else if let Some(from) = *pickup {
1342 if reservations.holder(Target::Pickup(from)) != Some(entity) {
1343 Step::Done
1344 } else if tile.0 != from {
1345 walk_to(&map, tile.0, from, &mut path)
1346 } else {
1347 let kind = items
1348 .iter()
1349 .find(|(_, _, pos)| pos.0 == from)
1350 .map(|(_, item, _)| item.kind);
1351 let room = kind.map_or(0, |kind| {
1352 room_for(items.iter().map(|(_, i, p)| (i, p)), to, kind)
1353 });
1354 match take_items(&mut commands, &mut items, from, CARRY_CAPACITY.min(room))
1355 .filter(|load| load.count > 0)
1356 {
1357 Some(load) => {
1358 carrying.0 = Some(load);
1359 reservations.release(Target::Pickup(from));
1360 *pickup = None;
1361 Step::Continue
1362 }
1363 None => Step::Done,
1364 }
1365 }
1366 } else if let Some(load) = carrying.0 {
1367 if tile.0 != to {
1368 walk_to(&map, tile.0, to, &mut path)
1369 } else {
1370 let room = room_for(items.iter().map(|(_, i, p)| (i, p)), to, load.kind);
1371 let dropped = load.count.min(room);
1372 if dropped > 0 {
1373 drop_items(
1374 &mut commands,
1375 &mut items,
1376 &item_assets,
1377 to,
1378 load.kind,
1379 dropped,
1380 );
1381 }
1382 carrying.0 = (load.count > dropped).then_some(Item {
1383 count: load.count - dropped,
1384 ..load
1385 });
1386 Step::Done
1387 }
1388 } else {
1389 Step::Done
1390 }
1391 }
1392 };
1393 match step {
1394 Step::Continue => current.0 = Some(job),
1395 Step::Done => {
1396 reservations.release_all(entity);
1397 current.0 = None;
1398 let ordered_move = matches!(
1399 job,
1400 Job {
1401 task: Task::MoveTo(_),
1402 ordered: true
1403 }
1404 );
1405 idle.restart(if ordered_move {
1406 IDLE_AFTER_ORDER_SECS
1407 } else {
1408 rng.range_f32(WORK_PAUSE_SECS)
1409 });
1410 }
1411 }
1412 }
1413 }
1414
1415 #[cfg(test)]
1416 mod tests;