src/jobs.rs 34 K · 1069 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 colonist::{Colonist, Path, repath},
11 items::{
12 CARRY_CAPACITY, Carrying, Item, ItemAssets, ItemKind, ItemQuery, drop_items, room_for,
13 stock, take_items,
14 },
15 pathfinding::{find_path, find_path_adjacent, is_adjacent, nearest_tile},
16 rng::SimRng,
17 sim::SimSystems,
18 world::{Plant, Terrain, TileChanged, TilePos, WorldMap},
19 worldgen::WorldGenerated,
20 zones::{Zones, plan_expansion},
21 };
22
23 pub struct JobsPlugin;
24
25 impl Plugin for JobsPlugin {
26 fn build(&self, app: &mut App) {
27 app.insert_resource(SimRng::new(0x5EED))
28 .init_resource::<Reservations>()
29 .init_resource::<ColonyGoals>()
30 .add_message::<Order>()
31 .add_message::<TileChanged>()
32 .add_systems(
33 Update,
34 (
35 clear_reservations.run_if(on_message::<WorldGenerated>),
36 handle_orders,
37 ),
38 )
39 .add_systems(FixedUpdate, choose_jobs.in_set(SimSystems::Decide))
40 .add_systems(FixedUpdate, run_jobs.in_set(SimSystems::Act));
41 }
42 }
43
44 pub const EXPAND_TICKS: u32 = 120;
45 pub const EXPANSION_TILES: usize = 8;
46 pub const MIN_EMPTY_STOCKPILE_TILES: usize = 3;
47 const SEARCH_TILES: usize = 15000;
48 const WANDER_RADIUS: i32 = 8;
49 const IDLE_SECS: Range<f32> = 1.0..4.0;
50 const IDLE_AFTER_ORDER_SECS: f32 = 10.0;
51 const WORK_PAUSE_SECS: Range<f32> = 0.2..0.8;
52 const NEIGHBORS: [IVec2; 8] = [
53 IVec2::new(1, 0),
54 IVec2::new(-1, 0),
55 IVec2::new(0, 1),
56 IVec2::new(0, -1),
57 IVec2::new(1, 1),
58 IVec2::new(1, -1),
59 IVec2::new(-1, 1),
60 IVec2::new(-1, -1),
61 ];
62
63 #[derive(Component, Default, Debug, Clone, PartialEq)]
64 pub struct CurrentJob(pub Option<Job>);
65
66 #[derive(Debug, Clone, Copy, PartialEq)]
67 pub struct Job {
68 pub task: Task,
69 pub ordered: bool,
70 }
71
72 #[derive(Debug, Clone, Copy, PartialEq)]
73 pub enum Task {
74 MoveTo(UVec2),
75 Work {
76 site: UVec2,
77 kind: WorkKind,
78 progress: u32,
79 },
80 Haul {
81 pickup: Option<UVec2>,
82 to: UVec2,
83 },
84 ExpandStockpile {
85 site: UVec2,
86 progress: u32,
87 },
88 }
89
90 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
91 pub enum WorkKind {
92 Chop,
93 Mine,
94 }
95
96 impl WorkKind {
97 pub fn ticks(self) -> u32 {
98 match self {
99 WorkKind::Chop => 240,
100 WorkKind::Mine => 360,
101 }
102 }
103
104 fn complete(self, map: &mut WorldMap, site: UVec2) {
105 match self {
106 WorkKind::Chop => map.set_plant(site, None),
107 WorkKind::Mine => map.set(site, Terrain::Gravel),
108 }
109 }
110 }
111
112 pub struct SiteWork {
113 pub kind: WorkKind,
114 pub item: ItemKind,
115 pub count: u32,
116 }
117
118 pub fn site_work(map: &WorldMap, site: UVec2) -> Option<SiteWork> {
119 let p = site.as_ivec2();
120 if map.plant(p) == Some(Plant::Tree) {
121 return Some(SiteWork {
122 kind: WorkKind::Chop,
123 item: ItemKind::Wood,
124 count: 10,
125 });
126 }
127 let (item, count) = match map.get(p)? {
128 Terrain::Stone => (ItemKind::Stone, 10),
129 Terrain::IronOre => (ItemKind::Iron, 10),
130 Terrain::GoldOre => (ItemKind::Gold, 5),
131 _ => return None,
132 };
133 Some(SiteWork {
134 kind: WorkKind::Mine,
135 item,
136 count,
137 })
138 }
139
140 #[derive(Component, Debug)]
141 pub struct IdleTimer(Timer);
142
143 impl Default for IdleTimer {
144 fn default() -> Self {
145 Self(Timer::from_seconds(IDLE_SECS.start, TimerMode::Once))
146 }
147 }
148
149 impl IdleTimer {
150 fn restart(&mut self, secs: f32) {
151 self.0.set_duration(Duration::from_secs_f32(secs));
152 self.0.reset();
153 }
154 }
155
156 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
157 pub enum Target {
158 Site(UVec2),
159 Pickup(UVec2),
160 Storage(UVec2),
161 StockpileExpansion,
162 }
163
164 #[derive(Resource, Default, Debug)]
165 pub struct Reservations(HashMap<Target, Entity>);
166
167 impl Reservations {
168 pub fn reserve(&mut self, target: Target, by: Entity) {
169 self.0.insert(target, by);
170 }
171
172 pub fn holder(&self, target: Target) -> Option<Entity> {
173 self.0.get(&target).copied()
174 }
175
176 pub fn is_free(&self, target: Target) -> bool {
177 self.holder(target).is_none()
178 }
179
180 pub fn release(&mut self, target: Target) {
181 self.0.remove(&target);
182 }
183
184 pub fn release_all(&mut self, by: Entity) {
185 self.0.retain(|_, holder| *holder != by);
186 }
187 }
188
189 #[derive(Resource, Debug, Clone)]
190 pub struct ColonyGoals(pub HashMap<ItemKind, u32>);
191
192 impl Default for ColonyGoals {
193 fn default() -> Self {
194 Self(HashMap::from([
195 (ItemKind::Wood, 50),
196 (ItemKind::Stone, 40),
197 (ItemKind::Iron, 20),
198 (ItemKind::Gold, 10),
199 ]))
200 }
201 }
202
203 impl ColonyGoals {
204 pub fn goal(&self, kind: ItemKind) -> u32 {
205 self.0.get(&kind).copied().unwrap_or(0)
206 }
207
208 pub fn wanted(&self, kind: ItemKind, in_stock: u32, on_the_way: u32) -> bool {
209 in_stock + on_the_way < self.goal(kind)
210 }
211 }
212
213 #[derive(Message, Debug, Clone, Copy)]
214 pub struct Order {
215 pub colonist: Entity,
216 pub tile: UVec2,
217 }
218
219 fn clear_reservations(mut reservations: ResMut<Reservations>) {
220 reservations.0.clear();
221 }
222
223 fn handle_orders(
224 mut orders: MessageReader<Order>,
225 map: Res<WorldMap>,
226 zones: Res<Zones>,
227 items: Query<(&Item, &TilePos)>,
228 mut reservations: ResMut<Reservations>,
229 mut colonists: Query<(&TilePos, &mut Path, &mut CurrentJob)>,
230 ) {
231 for order in orders.read() {
232 let Ok((tile, mut path, mut job)) = colonists.get_mut(order.colonist) else {
233 continue;
234 };
235 let loose_item = items
236 .iter()
237 .find(|(_, pos)| pos.0 == order.tile && !zones.is_stockpile(pos.0));
238 let task = if let Some(work) = site_work(&map, order.tile) {
239 Some(Task::Work {
240 site: order.tile,
241 kind: work.kind,
242 progress: 0,
243 })
244 } else if let Some((item, _)) = loose_item {
245 find_storage(&map, &zones, &reservations, &items, order.tile, item.kind).map(|to| {
246 Task::Haul {
247 pickup: Some(order.tile),
248 to,
249 }
250 })
251 } else {
252 None
253 };
254 if let Some(task) = task {
255 reservations.release_all(order.colonist);
256 reserve_task(&mut reservations, task, order.colonist);
257 path.0.truncate(1);
258 job.0 = Some(Job {
259 task,
260 ordered: true,
261 });
262 } else if let Some(new_path) = repath(&map, tile.0, &path.0, order.tile) {
263 reservations.release_all(order.colonist);
264 path.0 = new_path;
265 job.0 = Some(Job {
266 task: Task::MoveTo(order.tile),
267 ordered: true,
268 });
269 }
270 }
271 }
272
273 fn reserve_task(reservations: &mut Reservations, task: Task, by: Entity) {
274 match task {
275 Task::MoveTo(_) => {}
276 Task::Work { site, .. } => reservations.reserve(Target::Site(site), by),
277 Task::Haul { pickup, to } => {
278 if let Some(pickup) = pickup {
279 reservations.reserve(Target::Pickup(pickup), by);
280 }
281 reservations.reserve(Target::Storage(to), by);
282 }
283 Task::ExpandStockpile { .. } => reservations.reserve(Target::StockpileExpansion, by),
284 }
285 }
286
287 pub fn find_work(
288 map: &WorldMap,
289 reservations: &Reservations,
290 from: UVec2,
291 wanted: impl Fn(ItemKind) -> bool,
292 ) -> Option<Task> {
293 let mut found = None;
294 nearest_tile(map, from, SEARCH_TILES, |stand| {
295 found = NEIGHBORS.iter().find_map(|&d| {
296 let site = stand.as_ivec2() + d;
297 if site.x < 0 || site.y < 0 {
298 return None;
299 }
300 let site = site.as_uvec2();
301 let work = site_work(map, site)?;
302 (wanted(work.item) && reservations.is_free(Target::Site(site))).then_some(Task::Work {
303 site,
304 kind: work.kind,
305 progress: 0,
306 })
307 });
308 found.is_some()
309 })?;
310 found
311 }
312
313 pub fn find_storage<'a>(
314 map: &WorldMap,
315 zones: &Zones,
316 reservations: &Reservations,
317 items: impl IntoIterator<Item = (&'a Item, &'a TilePos)> + Clone,
318 from: UVec2,
319 kind: ItemKind,
320 ) -> Option<UVec2> {
321 nearest_tile(map, from, SEARCH_TILES, |p| {
322 zones.is_stockpile(p)
323 && reservations.is_free(Target::Storage(p))
324 && room_for(items.clone().into_iter(), p, kind) > 0
325 })
326 }
327
328 pub fn find_haul(
329 map: &WorldMap,
330 zones: &Zones,
331 reservations: &Reservations,
332 items: &[(Item, TilePos)],
333 from: UVec2,
334 ) -> Option<Task> {
335 let loose: HashSet<UVec2> = items
336 .iter()
337 .map(|(_, pos)| pos.0)
338 .filter(|&p| !zones.is_stockpile(p) && reservations.is_free(Target::Pickup(p)))
339 .collect();
340 if loose.is_empty() {
341 return None;
342 }
343 let pickup = nearest_tile(map, from, SEARCH_TILES, |p| loose.contains(&p))?;
344 let (item, _) = items.iter().find(|(_, pos)| pos.0 == pickup)?;
345 let refs = items.iter().map(|(i, p)| (i, p));
346 let to = find_storage(map, zones, reservations, refs, pickup, item.kind)?;
347 Some(Task::Haul {
348 pickup: Some(pickup),
349 to,
350 })
351 }
352
353 pub fn stockpile_too_full(zones: &Zones, items: &[(Item, TilePos)]) -> bool {
354 let occupied: HashSet<UVec2> = items.iter().map(|(_, pos)| pos.0).collect();
355 zones.has_stockpile() && zones.empty_stockpile_tiles(&occupied) < MIN_EMPTY_STOCKPILE_TILES
356 }
357
358 pub fn find_expansion(
359 map: &WorldMap,
360 zones: &Zones,
361 reservations: &Reservations,
362 items: &[(Item, TilePos)],
363 ) -> Option<Task> {
364 if !reservations.is_free(Target::StockpileExpansion) || !stockpile_too_full(zones, items) {
365 return None;
366 }
367 let site = *plan_expansion(map, zones, EXPANSION_TILES).first()?;
368 Some(Task::ExpandStockpile { site, progress: 0 })
369 }
370
371 fn on_the_way(map: &WorldMap, jobs: impl Iterator<Item = Option<Job>>) -> HashMap<ItemKind, u32> {
372 let mut totals = HashMap::new();
373 for job in jobs.flatten() {
374 if let Task::Work { site, .. } = job.task
375 && let Some(work) = site_work(map, site)
376 {
377 *totals.entry(work.item).or_insert(0) += work.count;
378 }
379 }
380 totals
381 }
382
383 #[allow(clippy::too_many_arguments)]
384 fn choose_jobs(
385 mut commands: Commands,
386 time: Res<Time>,
387 map: Res<WorldMap>,
388 zones: Res<Zones>,
389 goals: Res<ColonyGoals>,
390 item_assets: Res<ItemAssets>,
391 mut rng: ResMut<SimRng>,
392 mut reservations: ResMut<Reservations>,
393 mut items: ItemQuery,
394 mut colonists: Query<
395 (
396 Entity,
397 &TilePos,
398 &mut Path,
399 &mut CurrentJob,
400 &mut IdleTimer,
401 &mut Carrying,
402 ),
403 With<Colonist>,
404 >,
405 ) {
406 let carried: Vec<Item> = colonists.iter().filter_map(|c| c.5.0).collect();
407 let stocks: HashMap<ItemKind, u32> = ItemKind::ALL
408 .iter()
409 .map(|&kind| {
410 let all = items.iter().map(|(_, item, _)| item).chain(carried.iter());
411 (kind, stock(all, kind))
412 })
413 .collect();
414 let mut pending = on_the_way(&map, colonists.iter().map(|c| c.3.0));
415 for (entity, tile, mut path, mut job, mut idle, mut carrying) in &mut colonists {
416 if job.0.is_some() || !path.0.is_empty() || !idle.0.tick(time.delta()).is_finished() {
417 continue;
418 }
419 let snapshot: Vec<(Item, TilePos)> =
420 items.iter().map(|(_, item, pos)| (*item, *pos)).collect();
421 let refs = snapshot.iter().map(|(i, p)| (i, p));
422 let wanted = |kind: ItemKind| {
423 goals.wanted(
424 kind,
425 stocks[&kind],
426 pending.get(&kind).copied().unwrap_or(0),
427 )
428 };
429 let task = if let Some(load) = carrying.0 {
430 let storage = find_storage(&map, &zones, &reservations, refs, tile.0, load.kind);
431 if storage.is_none() {
432 drop_items(
433 &mut commands,
434 &mut items,
435 &item_assets,
436 tile.0,
437 load.kind,
438 load.count,
439 );
440 carrying.0 = None;
441 }
442 storage.map(|to| Task::Haul { pickup: None, to })
443 } else {
444 find_expansion(&map, &zones, &reservations, &snapshot)
445 .or_else(|| find_work(&map, &reservations, tile.0, wanted))
446 .or_else(|| find_haul(&map, &zones, &reservations, &snapshot, tile.0))
447 };
448 if let Some(task) = task {
449 if let Task::Work { site, .. } = task
450 && let Some(work) = site_work(&map, site)
451 {
452 *pending.entry(work.item).or_insert(0) += work.count;
453 }
454 reserve_task(&mut reservations, task, entity);
455 job.0 = Some(Job {
456 task,
457 ordered: false,
458 });
459 continue;
460 }
461 let goal = tile.0.as_ivec2()
462 + IVec2::new(
463 rng.range_i32(-WANDER_RADIUS..WANDER_RADIUS + 1),
464 rng.range_i32(-WANDER_RADIUS..WANDER_RADIUS + 1),
465 );
466 if map.in_bounds(goal)
467 && let Some(wander_path) = find_path(&map, tile.0, goal.as_uvec2())
468 {
469 path.0 = wander_path.into();
470 idle.restart(rng.range_f32(IDLE_SECS));
471 }
472 }
473 }
474
475 enum Step {
476 Continue,
477 Done,
478 }
479
480 fn walk_to(map: &WorldMap, from: UVec2, to: UVec2, path: &mut Path) -> Step {
481 match find_path(map, from, to) {
482 Some(p) if !p.is_empty() => {
483 path.0 = p.into();
484 Step::Continue
485 }
486 _ => Step::Done,
487 }
488 }
489
490 #[allow(clippy::too_many_arguments)]
491 fn run_jobs(
492 mut commands: Commands,
493 mut map: ResMut<WorldMap>,
494 mut zones: ResMut<Zones>,
495 mut reservations: ResMut<Reservations>,
496 mut rng: ResMut<SimRng>,
497 item_assets: Res<ItemAssets>,
498 mut items: ItemQuery,
499 mut changed: MessageWriter<TileChanged>,
500 mut colonists: Query<(
501 Entity,
502 &TilePos,
503 &mut Path,
504 &mut CurrentJob,
505 &mut IdleTimer,
506 &mut Carrying,
507 )>,
508 ) {
509 for (entity, tile, mut path, mut current, mut idle, mut carrying) in &mut colonists {
510 let Some(mut job) = current.0 else {
511 continue;
512 };
513 if !path.0.is_empty() {
514 continue;
515 }
516 let step = match &mut job.task {
517 Task::MoveTo(_) => Step::Done,
518 Task::Work {
519 site,
520 kind,
521 progress,
522 } => {
523 let (site, kind) = (*site, *kind);
524 let work = site_work(&map, site).filter(|w| w.kind == kind);
525 let ours = reservations.holder(Target::Site(site)) == Some(entity);
526 match work {
527 Some(work) if ours => {
528 if !is_adjacent(tile.0.as_ivec2(), site.as_ivec2()) {
529 match find_path_adjacent(&map, tile.0, site) {
530 Some(p) if !p.is_empty() => {
531 path.0 = p.into();
532 Step::Continue
533 }
534 _ => Step::Done,
535 }
536 } else {
537 *progress += 1;
538 if *progress < kind.ticks() {
539 Step::Continue
540 } else {
541 kind.complete(&mut map, site);
542 changed.write(TileChanged(site));
543 drop_items(
544 &mut commands,
545 &mut items,
546 &item_assets,
547 site,
548 work.item,
549 work.count,
550 );
551 Step::Done
552 }
553 }
554 }
555 _ => Step::Done,
556 }
557 }
558 Task::ExpandStockpile { site, progress } => {
559 if tile.0 != *site {
560 walk_to(&map, tile.0, *site, &mut path)
561 } else {
562 *progress += 1;
563 if *progress >= EXPAND_TICKS {
564 let tiles = plan_expansion(&map, &zones, EXPANSION_TILES);
565 zones.add_tiles(&map, tiles);
566 Step::Done
567 } else {
568 Step::Continue
569 }
570 }
571 }
572 Task::Haul { pickup, to } => {
573 let to = *to;
574 let destination_valid = zones.is_stockpile(to)
575 && reservations.holder(Target::Storage(to)) == Some(entity);
576 if !destination_valid {
577 Step::Done
578 } else if let Some(from) = *pickup {
579 if reservations.holder(Target::Pickup(from)) != Some(entity) {
580 Step::Done
581 } else if tile.0 != from {
582 walk_to(&map, tile.0, from, &mut path)
583 } else {
584 let kind = items
585 .iter()
586 .find(|(_, _, pos)| pos.0 == from)
587 .map(|(_, item, _)| item.kind);
588 let room = kind.map_or(0, |kind| {
589 room_for(items.iter().map(|(_, i, p)| (i, p)), to, kind)
590 });
591 match take_items(&mut commands, &mut items, from, CARRY_CAPACITY.min(room))
592 .filter(|load| load.count > 0)
593 {
594 Some(load) => {
595 carrying.0 = Some(load);
596 reservations.release(Target::Pickup(from));
597 *pickup = None;
598 Step::Continue
599 }
600 None => Step::Done,
601 }
602 }
603 } else if let Some(load) = carrying.0 {
604 if tile.0 != to {
605 walk_to(&map, tile.0, to, &mut path)
606 } else {
607 let room = room_for(items.iter().map(|(_, i, p)| (i, p)), to, load.kind);
608 let dropped = load.count.min(room);
609 if dropped > 0 {
610 drop_items(
611 &mut commands,
612 &mut items,
613 &item_assets,
614 to,
615 load.kind,
616 dropped,
617 );
618 }
619 carrying.0 = (load.count > dropped).then_some(Item {
620 count: load.count - dropped,
621 ..load
622 });
623 Step::Done
624 }
625 } else {
626 Step::Done
627 }
628 }
629 };
630 match step {
631 Step::Continue => current.0 = Some(job),
632 Step::Done => {
633 reservations.release_all(entity);
634 current.0 = None;
635 let ordered_move = matches!(
636 job,
637 Job {
638 task: Task::MoveTo(_),
639 ordered: true
640 }
641 );
642 idle.restart(if ordered_move {
643 IDLE_AFTER_ORDER_SECS
644 } else {
645 rng.range_f32(WORK_PAUSE_SECS)
646 });
647 }
648 }
649 }
650 }
651
652 #[cfg(test)]
653 mod tests {
654 use super::*;
655 use crate::{
656 colonist::follow_path,
657 items::STACK_LIMIT,
658 sim::{SimPos, sim_pos},
659 world::Terrain,
660 };
661
662 const TICK: Duration = Duration::from_nanos(1_000_000_000 / 60);
663
664 fn test_app(map: WorldMap) -> App {
665 let mut app = App::new();
666 app.insert_resource(map)
667 .insert_resource(SimRng::new(1))
668 .init_resource::<Time>()
669 .init_resource::<Reservations>()
670 .init_resource::<ColonyGoals>()
671 .init_resource::<ItemAssets>()
672 .init_resource::<Zones>()
673 .add_message::<Order>()
674 .add_message::<TileChanged>()
675 .add_systems(
676 Update,
677 (handle_orders, choose_jobs, run_jobs, follow_path).chain(),
678 );
679 app
680 }
681
682 fn spawn_colonist(app: &mut App, tile: UVec2) -> Entity {
683 app.world_mut()
684 .spawn((
685 Colonist {
686 name: "Test".into(),
687 },
688 TilePos(tile),
689 sim_pos(WorldMap::tile_center(tile)),
690 ))
691 .id()
692 }
693
694 fn run_ticks(app: &mut App, ticks: u32) {
695 for _ in 0..ticks {
696 app.world_mut().resource_mut::<Time>().advance_by(TICK);
697 app.update();
698 }
699 }
700
701 fn map_with_trees(trees: &[UVec2]) -> WorldMap {
702 let mut map = WorldMap::filled(UVec2::new(20, 20), Terrain::Grass);
703 for &t in trees {
704 map.set_plant(t, Some(Plant::Tree));
705 }
706 map
707 }
708
709 fn wood_on_map(app: &mut App) -> u32 {
710 let world = app.world_mut();
711 let items: Vec<Item> = world.query::<&Item>().iter(world).copied().collect();
712 stock(items.iter(), ItemKind::Wood)
713 }
714
715 fn items_at(app: &mut App) -> Vec<(UVec2, u32)> {
716 let world = app.world_mut();
717 let mut items: Vec<(UVec2, u32)> = world
718 .query::<(&Item, &TilePos)>()
719 .iter(world)
720 .map(|(item, pos)| (pos.0, item.count))
721 .collect();
722 items.sort_by_key(|(p, _)| (p.y, p.x));
723 items
724 }
725
726 fn put_wood(app: &mut App, tile: UVec2, count: u32) {
727 app.world_mut().spawn((
728 Item {
729 kind: ItemKind::Wood,
730 count,
731 },
732 TilePos(tile),
733 ));
734 }
735
736 fn set_goals(app: &mut App, goals: &[(ItemKind, u32)]) {
737 *app.world_mut().resource_mut::<ColonyGoals>() =
738 ColonyGoals(goals.iter().copied().collect());
739 }
740
741 fn stockpile_size(app: &App) -> usize {
742 let map = app.world().resource::<WorldMap>();
743 let zones = app.world().resource::<Zones>();
744 map.positions().filter(|&p| zones.is_stockpile(p)).count()
745 }
746
747 fn stored(app: &mut App) -> u32 {
748 let zones = app.world().resource::<Zones>().clone();
749 items_at(app)
750 .iter()
751 .filter(|(p, _)| zones.is_stockpile(*p))
752 .map(|(_, c)| c)
753 .sum()
754 }
755
756 fn hauling_app(stockpile: (IVec2, IVec2)) -> App {
757 let mut app = test_app(map_with_trees(&[]));
758 set_goals(&mut app, &[]);
759 let map = app.world().resource::<WorldMap>().clone();
760 app.world_mut()
761 .resource_mut::<Zones>()
762 .add_stockpile(&map, stockpile.0, stockpile.1);
763 app
764 }
765
766 fn carrying(app: &App, colonist: Entity) -> Option<Item> {
767 app.world().get::<Carrying>(colonist).unwrap().0
768 }
769
770 fn job(app: &App, colonist: Entity) -> Option<Job> {
771 app.world().get::<CurrentJob>(colonist).unwrap().0
772 }
773
774 #[test]
775 fn idle_colonists_cut_trees_when_the_colony_needs_wood() {
776 let tree = UVec2::new(8, 5);
777 let mut app = test_app(map_with_trees(&[tree]));
778 spawn_colonist(&mut app, UVec2::new(2, 5));
779 run_ticks(&mut app, 60 * 15);
780 assert_eq!(
781 app.world().resource::<WorldMap>().plant(tree.as_ivec2()),
782 None
783 );
784 assert_eq!(wood_on_map(&mut app), 10);
785 assert!(app.world().resource::<Reservations>().0.is_empty());
786 }
787
788 #[test]
789 fn colonists_leave_trees_alone_when_there_is_enough_wood() {
790 let tree = UVec2::new(8, 5);
791 let mut app = test_app(map_with_trees(&[tree]));
792 set_goals(&mut app, &[]);
793 spawn_colonist(&mut app, UVec2::new(2, 5));
794 run_ticks(&mut app, 60 * 15);
795 assert_eq!(
796 app.world().resource::<WorldMap>().plant(tree.as_ivec2()),
797 Some(Plant::Tree)
798 );
799 }
800
801 #[test]
802 fn colonists_never_pick_the_same_tree() {
803 let trees = [UVec2::new(10, 10), UVec2::new(10, 11)];
804 let mut app = test_app(map_with_trees(&trees));
805 let a = spawn_colonist(&mut app, UVec2::new(5, 10));
806 let b = spawn_colonist(&mut app, UVec2::new(5, 11));
807 run_ticks(&mut app, 61);
808 let target = |c| match job(&app, c) {
809 Some(Job {
810 task: Task::Work { site, .. },
811 ..
812 }) => site,
813 other => panic!("expected chop job, got {other:?}"),
814 };
815 assert_ne!(target(a), target(b));
816 }
817
818 // Each tree or rock being worked will add its yield; counting that as material on the way
819 // keeps the whole colony from felling a forest when only a little more wood is needed.
820 #[test]
821 fn work_in_progress_counts_towards_goals() {
822 let goals = ColonyGoals(HashMap::from([(ItemKind::Wood, 25)]));
823 assert!(goals.wanted(ItemKind::Wood, 10, 10));
824 assert!(!goals.wanted(ItemKind::Wood, 10, 20));
825 assert!(!goals.wanted(ItemKind::Wood, 30, 0));
826 assert!(!goals.wanted(ItemKind::Gold, 0, 0));
827 }
828
829 #[test]
830 fn ordered_chop_ignores_the_wood_goal() {
831 let tree = UVec2::new(8, 5);
832 let mut app = test_app(map_with_trees(&[tree]));
833 set_goals(&mut app, &[]);
834 let colonist = spawn_colonist(&mut app, UVec2::new(2, 5));
835 app.world_mut().write_message(Order {
836 colonist,
837 tile: tree,
838 });
839 run_ticks(&mut app, 60 * 15);
840 assert_eq!(
841 app.world().resource::<WorldMap>().plant(tree.as_ivec2()),
842 None
843 );
844 }
845
846 #[test]
847 fn ordering_a_move_cancels_the_current_job_and_frees_its_tree() {
848 let tree = UVec2::new(15, 5);
849 let mut app = test_app(map_with_trees(&[tree]));
850 let colonist = spawn_colonist(&mut app, UVec2::new(2, 5));
851 run_ticks(&mut app, 61);
852 assert!(matches!(
853 job(&app, colonist),
854 Some(Job {
855 task: Task::Work { .. },
856 ..
857 })
858 ));
859 app.world_mut().write_message(Order {
860 colonist,
861 tile: UVec2::new(2, 15),
862 });
863 run_ticks(&mut app, 1);
864 assert_eq!(
865 job(&app, colonist).map(|j| j.task),
866 Some(Task::MoveTo(UVec2::new(2, 15)))
867 );
868 assert!(
869 app.world()
870 .resource::<Reservations>()
871 .is_free(Target::Site(tree))
872 );
873 }
874
875 #[test]
876 fn idle_colonists_haul_loose_items_into_the_stockpile() {
877 let mut app = hauling_app((IVec2::new(15, 15), IVec2::new(17, 15)));
878 put_wood(&mut app, UVec2::new(5, 5), 20);
879 spawn_colonist(&mut app, UVec2::new(2, 5));
880 run_ticks(&mut app, 60 * 15);
881 assert_eq!(items_at(&mut app), vec![(UVec2::new(15, 15), 20)]);
882 }
883
884 #[test]
885 fn large_stacks_take_several_trips_and_spill_into_more_stockpile_tiles() {
886 let mut app = hauling_app((IVec2::new(15, 15), IVec2::new(17, 15)));
887 put_wood(&mut app, UVec2::new(5, 5), STACK_LIMIT + 10);
888 spawn_colonist(&mut app, UVec2::new(2, 5));
889 run_ticks(&mut app, 60 * 40);
890 let stacks = items_at(&mut app);
891 assert_eq!(stored(&mut app), STACK_LIMIT + 10);
892 assert!(stacks.len() >= 2);
893 assert!(stacks.iter().all(|(_, count)| *count <= STACK_LIMIT));
894 }
895
896 #[test]
897 fn items_already_in_a_stockpile_stay_put() {
898 let mut app = hauling_app((IVec2::new(5, 5), IVec2::new(16, 16)));
899 put_wood(&mut app, UVec2::new(5, 5), 20);
900 let colonist = spawn_colonist(&mut app, UVec2::new(2, 5));
901 run_ticks(&mut app, 60 * 5);
902 assert_eq!(items_at(&mut app), vec![(UVec2::new(5, 5), 20)]);
903 assert_eq!(carrying(&app, colonist), None);
904 }
905
906 #[test]
907 fn without_a_stockpile_nothing_is_hauled() {
908 let mut app = test_app(map_with_trees(&[]));
909 set_goals(&mut app, &[]);
910 put_wood(&mut app, UVec2::new(5, 5), 20);
911 spawn_colonist(&mut app, UVec2::new(2, 5));
912 run_ticks(&mut app, 60 * 5);
913 assert_eq!(items_at(&mut app), vec![(UVec2::new(5, 5), 20)]);
914 }
915
916 #[test]
917 fn two_colonists_never_haul_the_same_stack() {
918 let mut app = hauling_app((IVec2::new(15, 15), IVec2::new(16, 16)));
919 put_wood(&mut app, UVec2::new(5, 5), 10);
920 put_wood(&mut app, UVec2::new(5, 6), 10);
921 let a = spawn_colonist(&mut app, UVec2::new(2, 5));
922 let b = spawn_colonist(&mut app, UVec2::new(2, 6));
923 run_ticks(&mut app, 61);
924 let pickup = |c| match job(&app, c).map(|j| j.task) {
925 Some(Task::Haul { pickup, .. }) => pickup,
926 other => panic!("expected haul, got {other:?}"),
927 };
928 assert_ne!(pickup(a), pickup(b));
929 }
930
931 // Orders interrupt whatever a colonist is doing, but whatever it is carrying stays in its
932 // hands; once it is idle again it finishes delivering it instead of dropping it anywhere.
933 #[test]
934 fn interrupted_haulers_deliver_their_load_later() {
935 let mut app = hauling_app((IVec2::new(15, 15), IVec2::new(17, 15)));
936 put_wood(&mut app, UVec2::new(4, 5), 20);
937 let colonist = spawn_colonist(&mut app, UVec2::new(2, 5));
938 let mut ticks = 0;
939 while carrying(&app, colonist).is_none() && ticks < 60 * 10 {
940 run_ticks(&mut app, 1);
941 ticks += 1;
942 }
943 app.world_mut().write_message(Order {
944 colonist,
945 tile: UVec2::new(2, 2),
946 });
947 run_ticks(&mut app, 60 * 3);
948 assert!(carrying(&app, colonist).is_some());
949 run_ticks(&mut app, 60 * 20);
950 assert_eq!(carrying(&app, colonist), None);
951 assert_eq!(stored(&mut app), 20);
952 }
953
954 #[test]
955 fn right_clicking_loose_items_orders_a_haul() {
956 let mut app = hauling_app((IVec2::new(15, 15), IVec2::new(15, 15)));
957 set_goals(&mut app, &[(ItemKind::Wood, 1000)]);
958 put_wood(&mut app, UVec2::new(5, 5), 20);
959 let colonist = spawn_colonist(&mut app, UVec2::new(2, 5));
960 app.world_mut().write_message(Order {
961 colonist,
962 tile: UVec2::new(5, 5),
963 });
964 run_ticks(&mut app, 1);
965 assert!(matches!(
966 job(&app, colonist),
967 Some(Job {
968 task: Task::Haul { .. },
969 ordered: true
970 })
971 ));
972 }
973
974 fn mining_map() -> WorldMap {
975 let mut map = WorldMap::filled(UVec2::new(20, 20), Terrain::Grass);
976 for y in 0..20 {
977 map.set(UVec2::new(10, y), Terrain::Stone);
978 }
979 map.set(UVec2::new(10, 5), Terrain::IronOre);
980 map
981 }
982
983 fn items_of(app: &mut App, kind: ItemKind) -> u32 {
984 let world = app.world_mut();
985 let items: Vec<Item> = world.query::<&Item>().iter(world).copied().collect();
986 stock(items.iter(), kind)
987 }
988
989 #[test]
990 fn colonists_mine_rock_the_colony_needs_and_leave_gravel() {
991 let mut app = test_app(mining_map());
992 set_goals(&mut app, &[(ItemKind::Stone, 10)]);
993 spawn_colonist(&mut app, UVec2::new(8, 12));
994 run_ticks(&mut app, 60 * 12);
995 assert_eq!(items_of(&mut app, ItemKind::Stone), 10);
996 assert_eq!(items_of(&mut app, ItemKind::Iron), 0);
997 let map = app.world().resource::<WorldMap>();
998 let gravel = map
999 .positions()
1000 .filter(|p| map.get(p.as_ivec2()) == Some(Terrain::Gravel))
1001 .count();
1002 assert_eq!(gravel, 1);
1003 }
1004
1005 #[test]
1006 fn colonists_walk_past_rock_they_do_not_need() {
1007 let mut app = test_app(mining_map());
1008 set_goals(&mut app, &[(ItemKind::Iron, 10)]);
1009 spawn_colonist(&mut app, UVec2::new(8, 15));
1010 run_ticks(&mut app, 60 * 15);
1011 assert_eq!(items_of(&mut app, ItemKind::Iron), 10);
1012 assert_eq!(items_of(&mut app, ItemKind::Stone), 0);
1013 assert_eq!(
1014 app.world().resource::<WorldMap>().get(IVec2::new(10, 5)),
1015 Some(Terrain::Gravel)
1016 );
1017 }
1018
1019 #[test]
1020 fn right_clicking_rock_orders_mining_regardless_of_goals() {
1021 let mut app = test_app(mining_map());
1022 set_goals(&mut app, &[]);
1023 let colonist = spawn_colonist(&mut app, UVec2::new(8, 12));
1024 app.world_mut().write_message(Order {
1025 colonist,
1026 tile: UVec2::new(10, 12),
1027 });
1028 run_ticks(&mut app, 60 * 10);
1029 assert_eq!(
1030 app.world().resource::<WorldMap>().get(IVec2::new(10, 12)),
1031 Some(Terrain::Gravel)
1032 );
1033 }
1034
1035 #[test]
1036 fn colonists_expand_a_stockpile_that_is_nearly_full() {
1037 let mut app = hauling_app((IVec2::new(5, 5), IVec2::new(6, 5)));
1038 let colonist = spawn_colonist(&mut app, UVec2::new(2, 5));
1039 run_ticks(&mut app, 61);
1040 assert!(matches!(
1041 job(&app, colonist).map(|j| j.task),
1042 Some(Task::ExpandStockpile { .. })
1043 ));
1044 run_ticks(&mut app, 60 * 5);
1045 assert_eq!(stockpile_size(&app), 2 + EXPANSION_TILES);
1046 }
1047
1048 #[test]
1049 fn roomy_stockpiles_are_left_alone() {
1050 let mut app = hauling_app((IVec2::new(5, 5), IVec2::new(8, 5)));
1051 spawn_colonist(&mut app, UVec2::new(2, 5));
1052 run_ticks(&mut app, 60 * 5);
1053 assert_eq!(stockpile_size(&app), 4);
1054 }
1055
1056 #[test]
1057 fn colonists_chop_from_a_neighbouring_tile() {
1058 let tree = UVec2::new(8, 5);
1059 let mut app = test_app(map_with_trees(&[tree]));
1060 let colonist = spawn_colonist(&mut app, UVec2::new(2, 5));
1061 run_ticks(&mut app, 60 * 5);
1062 let tile = app.world().get::<TilePos>(colonist).unwrap().0;
1063 assert!(is_adjacent(tile.as_ivec2(), tree.as_ivec2()));
1064 assert_eq!(
1065 app.world().get::<SimPos>(colonist).unwrap().0,
1066 WorldMap::tile_center(tile)
1067 );
1068 }
1069 }