Add mining, material goals and autonomous stockpile expansion

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-24 09:56 UTC
commit
cdfd95fe3ef0616f9ec9f4b864dd9bfac7b45b8d
parent
b72352167a
tree
browse at this commit

6 files changed +535 -139

src/items.rs +23 -8

@@ -22,14 +22,25 @@ impl Plugin for ItemsPlugin {
22 22 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
23 23 pub enum ItemKind {
24 24 Wood,
25 + Stone,
26 + Iron,
27 + Gold,
25 28 }
26 29
27 30 impl ItemKind {
28 - pub const ALL: [ItemKind; 1] = [ItemKind::Wood];
31 + pub const ALL: [ItemKind; 4] = [
32 + ItemKind::Wood,
33 + ItemKind::Stone,
34 + ItemKind::Iron,
35 + ItemKind::Gold,
36 + ];
29 37
30 38 fn color(self) -> Color {
31 39 match self {
32 40 ItemKind::Wood => Color::srgb_u8(150, 104, 62),
41 + ItemKind::Stone => Color::srgb_u8(170, 170, 176),
42 + ItemKind::Iron => Color::srgb_u8(178, 104, 72),
43 + ItemKind::Gold => Color::srgb_u8(236, 196, 60),
33 44 }
34 45 }
35 46 }
@@ -89,17 +100,21 @@ fn create_item_assets(
89 100 }
90 101
91 102 fn show_carried(
103 + assets: Res<ItemAssets>,
92 104 colonists: Query<(&Carrying, &Children), Changed<Carrying>>,
93 - mut visuals: Query<&mut Visibility, With<CarriedVisual>>,
105 + mut visuals: Query<(&mut Visibility, &mut MeshMaterial2d<ColorMaterial>), With<CarriedVisual>>,
94 106 ) {
95 107 for (carrying, children) in &colonists {
96 108 for &child in children {
97 - if let Ok(mut visibility) = visuals.get_mut(child) {
98 - *visibility = if carrying.0.is_some() {
99 - Visibility::Inherited
100 - } else {
101 - Visibility::Hidden
102 - };
109 + let Ok((mut visibility, mut material)) = visuals.get_mut(child) else {
110 + continue;
111 + };
112 + match carrying.0 {
113 + Some(item) => {
114 + *visibility = Visibility::Inherited;
115 + material.0 = assets.material(item.kind);
116 + }
117 + None => *visibility = Visibility::Hidden,
103 118 }
104 119 }
105 120 }

src/jobs.rs +354 -97

@@ -15,9 +15,9 @@ use crate::{
15 15 pathfinding::{find_path, find_path_adjacent, is_adjacent, nearest_tile},
16 16 rng::SimRng,
17 17 sim::SimSystems,
18 - world::{Plant, TileChanged, TilePos, WorldMap},
18 + world::{Plant, Terrain, TileChanged, TilePos, WorldMap},
19 19 worldgen::WorldGenerated,
20 - zones::Zones,
20 + zones::{Zones, plan_expansion},
21 21 };
22 22
23 23 pub struct JobsPlugin;
@@ -41,13 +41,24 @@ impl Plugin for JobsPlugin {
41 41 }
42 42 }
43 43
44 -pub const CHOP_TICKS: u32 = 240;
45 -pub const WOOD_PER_TREE: u32 = 10;
46 -const SEARCH_TILES: usize = 6000;
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;
47 48 const WANDER_RADIUS: i32 = 8;
48 49 const IDLE_SECS: Range<f32> = 1.0..4.0;
49 50 const IDLE_AFTER_ORDER_SECS: f32 = 10.0;
50 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 +];
51 62
52 63 #[derive(Component, Default, Debug, Clone, PartialEq)]
53 64 pub struct CurrentJob(pub Option<Job>);
@@ -61,8 +72,69 @@ pub struct Job {
61 72 #[derive(Debug, Clone, Copy, PartialEq)]
62 73 pub enum Task {
63 74 MoveTo(UVec2),
64 - Chop { tree: UVec2, progress: u32 },
65 - Haul { pickup: Option<UVec2>, to: 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 + })
66 138 }
67 139
68 140 #[derive(Component, Debug)]
@@ -83,9 +155,10 @@ impl IdleTimer {
83 155
84 156 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
85 157 pub enum Target {
86 - Tree(UVec2),
158 + Site(UVec2),
87 159 Pickup(UVec2),
88 160 Storage(UVec2),
161 + StockpileExpansion,
89 162 }
90 163
91 164 #[derive(Resource, Default, Debug)]
@@ -113,14 +186,27 @@ impl Reservations {
113 186 }
114 187 }
115 188
116 -#[derive(Resource, Debug)]
117 -pub struct ColonyGoals {
118 - pub wood: u32,
119 -}
189 +#[derive(Resource, Debug, Clone)]
190 +pub struct ColonyGoals(pub HashMap<ItemKind, u32>);
120 191
121 192 impl Default for ColonyGoals {
122 193 fn default() -> Self {
123 - Self { wood: 50 }
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)
124 210 }
125 211 }
126 212
@@ -149,9 +235,10 @@ fn handle_orders(
149 235 let loose_item = items
150 236 .iter()
151 237 .find(|(_, pos)| pos.0 == order.tile && !zones.is_stockpile(pos.0));
152 - let task = if map.plant(order.tile.as_ivec2()) == Some(Plant::Tree) {
153 - Some(Task::Chop {
154 - tree: order.tile,
238 + let task = if let Some(work) = site_work(&map, order.tile) {
239 + Some(Task::Work {
240 + site: order.tile,
241 + kind: work.kind,
155 242 progress: 0,
156 243 })
157 244 } else if let Some((item, _)) = loose_item {
@@ -186,26 +273,41 @@ fn handle_orders(
186 273 fn reserve_task(reservations: &mut Reservations, task: Task, by: Entity) {
187 274 match task {
188 275 Task::MoveTo(_) => {}
189 - Task::Chop { tree, .. } => reservations.reserve(Target::Tree(tree), by),
276 + Task::Work { site, .. } => reservations.reserve(Target::Site(site), by),
190 277 Task::Haul { pickup, to } => {
191 278 if let Some(pickup) = pickup {
192 279 reservations.reserve(Target::Pickup(pickup), by);
193 280 }
194 281 reservations.reserve(Target::Storage(to), by);
195 282 }
283 + Task::ExpandStockpile { .. } => reservations.reserve(Target::StockpileExpansion, by),
196 284 }
197 285 }
198 286
199 -pub fn wood_wanted(goals: &ColonyGoals, in_stock: u32, trees_being_chopped: u32) -> bool {
200 - in_stock + trees_being_chopped * WOOD_PER_TREE < goals.wood
201 -}
202 -
203 -pub fn find_tree(map: &WorldMap, reservations: &Reservations, from: UVec2) -> Option<UVec2> {
204 - let tree = nearest_tile(map, from, SEARCH_TILES, |p| {
205 - map.plant(p.as_ivec2()) == Some(Plant::Tree) && reservations.is_free(Target::Tree(p))
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()
206 309 })?;
207 - find_path_adjacent(map, from, tree)?;
208 - Some(tree)
310 + found
209 311 }
210 312
211 313 pub fn find_storage<'a>(
@@ -248,6 +350,36 @@ pub fn find_haul(
248 350 })
249 351 }
250 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 +
251 383 #[allow(clippy::too_many_arguments)]
252 384 fn choose_jobs(
253 385 mut commands: Commands,
@@ -272,22 +404,14 @@ fn choose_jobs(
272 404 >,
273 405 ) {
274 406 let carried: Vec<Item> = colonists.iter().filter_map(|c| c.5.0).collect();
275 - let wood = stock(
276 - items.iter().map(|(_, item, _)| item).chain(carried.iter()),
277 - ItemKind::Wood,
278 - );
279 - let mut chopping = colonists
407 + let stocks: HashMap<ItemKind, u32> = ItemKind::ALL
280 408 .iter()
281 - .filter(|c| {
282 - matches!(
283 - c.3.0,
284 - Some(Job {
285 - task: Task::Chop { .. },
286 - ..
287 - })
288 - )
409 + .map(|&kind| {
410 + let all = items.iter().map(|(_, item, _)| item).chain(carried.iter());
411 + (kind, stock(all, kind))
289 412 })
290 - .count() as u32;
413 + .collect();
414 + let mut pending = on_the_way(&map, colonists.iter().map(|c| c.3.0));
291 415 for (entity, tile, mut path, mut job, mut idle, mut carrying) in &mut colonists {
292 416 if job.0.is_some() || !path.0.is_empty() || !idle.0.tick(time.delta()).is_finished() {
293 417 continue;
@@ -295,6 +419,13 @@ fn choose_jobs(
295 419 let snapshot: Vec<(Item, TilePos)> =
296 420 items.iter().map(|(_, item, pos)| (*item, *pos)).collect();
297 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 + };
298 429 let task = if let Some(load) = carrying.0 {
299 430 let storage = find_storage(&map, &zones, &reservations, refs, tile.0, load.kind);
300 431 if storage.is_none() {
@@ -309,15 +440,17 @@ fn choose_jobs(
309 440 carrying.0 = None;
310 441 }
311 442 storage.map(|to| Task::Haul { pickup: None, to })
312 - } else if wood_wanted(&goals, wood, chopping)
313 - && let Some(tree) = find_tree(&map, &reservations, tile.0)
314 - {
315 - chopping += 1;
316 - Some(Task::Chop { tree, progress: 0 })
317 443 } else {
318 - find_haul(&map, &zones, &reservations, &snapshot, tile.0)
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))
319 447 };
320 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 + }
321 454 reserve_task(&mut reservations, task, entity);
322 455 job.0 = Some(Job {
323 456 task,
@@ -358,7 +491,7 @@ fn walk_to(map: &WorldMap, from: UVec2, to: UVec2, path: &mut Path) -> Step {
358 491 fn run_jobs(
359 492 mut commands: Commands,
360 493 mut map: ResMut<WorldMap>,
361 - zones: Res<Zones>,
494 + mut zones: ResMut<Zones>,
362 495 mut reservations: ResMut<Reservations>,
363 496 mut rng: ResMut<SimRng>,
364 497 item_assets: Res<ItemAssets>,
@@ -382,36 +515,57 @@ fn run_jobs(
382 515 }
383 516 let step = match &mut job.task {
384 517 Task::MoveTo(_) => Step::Done,
385 - Task::Chop { tree, progress } => {
386 - let tree = *tree;
387 - let still_ours = map.plant(tree.as_ivec2()) == Some(Plant::Tree)
388 - && reservations.holder(Target::Tree(tree)) == Some(entity);
389 - if !still_ours {
390 - Step::Done
391 - } else if !is_adjacent(tile.0.as_ivec2(), tree.as_ivec2()) {
392 - match find_path_adjacent(&map, tile.0, tree) {
393 - Some(p) if !p.is_empty() => {
394 - path.0 = p.into();
395 - Step::Continue
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 + }
396 553 }
397 - _ => Step::Done,
398 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)
399 561 } else {
400 562 *progress += 1;
401 - if *progress < CHOP_TICKS {
402 - Step::Continue
403 - } else {
404 - map.set_plant(tree, None);
405 - changed.write(TileChanged(tree));
406 - drop_items(
407 - &mut commands,
408 - &mut items,
409 - &item_assets,
410 - tree,
411 - ItemKind::Wood,
412 - WOOD_PER_TREE,
413 - );
563 + if *progress >= EXPAND_TICKS {
564 + let tiles = plan_expansion(&map, &zones, EXPANSION_TILES);
565 + zones.add_tiles(&map, tiles);
414 566 Step::Done
567 + } else {
568 + Step::Continue
415 569 }
416 570 }
417 571 }
@@ -579,9 +733,29 @@ mod tests {
579 733 ));
580 734 }
581 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 +
582 756 fn hauling_app(stockpile: (IVec2, IVec2)) -> App {
583 757 let mut app = test_app(map_with_trees(&[]));
584 - app.world_mut().resource_mut::<ColonyGoals>().wood = 0;
758 + set_goals(&mut app, &[]);
585 759 let map = app.world().resource::<WorldMap>().clone();
586 760 app.world_mut()
587 761 .resource_mut::<Zones>()
@@ -607,7 +781,7 @@ mod tests {
607 781 app.world().resource::<WorldMap>().plant(tree.as_ivec2()),
608 782 None
609 783 );
610 - assert_eq!(wood_on_map(&mut app), WOOD_PER_TREE);
784 + assert_eq!(wood_on_map(&mut app), 10);
611 785 assert!(app.world().resource::<Reservations>().0.is_empty());
612 786 }
613 787
@@ -615,7 +789,7 @@ mod tests {
615 789 fn colonists_leave_trees_alone_when_there_is_enough_wood() {
616 790 let tree = UVec2::new(8, 5);
617 791 let mut app = test_app(map_with_trees(&[tree]));
618 - app.world_mut().resource_mut::<ColonyGoals>().wood = 0;
792 + set_goals(&mut app, &[]);
619 793 spawn_colonist(&mut app, UVec2::new(2, 5));
620 794 run_ticks(&mut app, 60 * 15);
621 795 assert_eq!(
@@ -633,29 +807,30 @@ mod tests {
633 807 run_ticks(&mut app, 61);
634 808 let target = |c| match job(&app, c) {
635 809 Some(Job {
636 - task: Task::Chop { tree, .. },
810 + task: Task::Work { site, .. },
637 811 ..
638 - }) => tree,
812 + }) => site,
639 813 other => panic!("expected chop job, got {other:?}"),
640 814 };
641 815 assert_ne!(target(a), target(b));
642 816 }
643 817
644 - // Each chopped tree will add WOOD_PER_TREE; counting trees already being chopped as future
645 - // wood keeps the whole colony from felling a forest when only a little more wood is needed.
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.
646 820 #[test]
647 - fn trees_already_being_cut_count_towards_the_wood_goal() {
648 - let goals = ColonyGoals { wood: 25 };
649 - assert!(wood_wanted(&goals, 10, 1));
650 - assert!(!wood_wanted(&goals, 10, 2));
651 - assert!(!wood_wanted(&goals, 30, 0));
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));
652 827 }
653 828
654 829 #[test]
655 830 fn ordered_chop_ignores_the_wood_goal() {
656 831 let tree = UVec2::new(8, 5);
657 832 let mut app = test_app(map_with_trees(&[tree]));
658 - app.world_mut().resource_mut::<ColonyGoals>().wood = 0;
833 + set_goals(&mut app, &[]);
659 834 let colonist = spawn_colonist(&mut app, UVec2::new(2, 5));
660 835 app.world_mut().write_message(Order {
661 836 colonist,
@@ -677,7 +852,7 @@ mod tests {
677 852 assert!(matches!(
678 853 job(&app, colonist),
679 854 Some(Job {
680 - task: Task::Chop { .. },
855 + task: Task::Work { .. },
681 856 ..
682 857 })
683 858 ));
@@ -693,13 +868,13 @@ mod tests {
693 868 assert!(
694 869 app.world()
695 870 .resource::<Reservations>()
696 - .is_free(Target::Tree(tree))
871 + .is_free(Target::Site(tree))
697 872 );
698 873 }
699 874
700 875 #[test]
701 876 fn idle_colonists_haul_loose_items_into_the_stockpile() {
702 - let mut app = hauling_app((IVec2::new(15, 15), IVec2::new(15, 15)));
877 + let mut app = hauling_app((IVec2::new(15, 15), IVec2::new(17, 15)));
703 878 put_wood(&mut app, UVec2::new(5, 5), 20);
704 879 spawn_colonist(&mut app, UVec2::new(2, 5));
705 880 run_ticks(&mut app, 60 * 15);
@@ -708,14 +883,14 @@ mod tests {
708 883
709 884 #[test]
710 885 fn large_stacks_take_several_trips_and_spill_into_more_stockpile_tiles() {
711 - let mut app = hauling_app((IVec2::new(15, 15), IVec2::new(16, 15)));
886 + let mut app = hauling_app((IVec2::new(15, 15), IVec2::new(17, 15)));
712 887 put_wood(&mut app, UVec2::new(5, 5), STACK_LIMIT + 10);
713 888 spawn_colonist(&mut app, UVec2::new(2, 5));
714 889 run_ticks(&mut app, 60 * 40);
715 - assert_eq!(
716 - items_at(&mut app),
717 - vec![(UVec2::new(15, 15), STACK_LIMIT), (UVec2::new(16, 15), 10)]
718 - );
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));
719 894 }
720 895
721 896 #[test]
@@ -731,7 +906,7 @@ mod tests {
731 906 #[test]
732 907 fn without_a_stockpile_nothing_is_hauled() {
733 908 let mut app = test_app(map_with_trees(&[]));
734 - app.world_mut().resource_mut::<ColonyGoals>().wood = 0;
909 + set_goals(&mut app, &[]);
735 910 put_wood(&mut app, UVec2::new(5, 5), 20);
736 911 spawn_colonist(&mut app, UVec2::new(2, 5));
737 912 run_ticks(&mut app, 60 * 5);
@@ -757,7 +932,7 @@ mod tests {
757 932 // hands; once it is idle again it finishes delivering it instead of dropping it anywhere.
758 933 #[test]
759 934 fn interrupted_haulers_deliver_their_load_later() {
760 - let mut app = hauling_app((IVec2::new(15, 15), IVec2::new(15, 15)));
935 + let mut app = hauling_app((IVec2::new(15, 15), IVec2::new(17, 15)));
761 936 put_wood(&mut app, UVec2::new(4, 5), 20);
762 937 let colonist = spawn_colonist(&mut app, UVec2::new(2, 5));
763 938 let mut ticks = 0;
@@ -773,13 +948,13 @@ mod tests {
773 948 assert!(carrying(&app, colonist).is_some());
774 949 run_ticks(&mut app, 60 * 20);
775 950 assert_eq!(carrying(&app, colonist), None);
776 - assert_eq!(items_at(&mut app), vec![(UVec2::new(15, 15), 20)]);
951 + assert_eq!(stored(&mut app), 20);
777 952 }
778 953
779 954 #[test]
780 955 fn right_clicking_loose_items_orders_a_haul() {
781 956 let mut app = hauling_app((IVec2::new(15, 15), IVec2::new(15, 15)));
782 - app.world_mut().resource_mut::<ColonyGoals>().wood = 1000;
957 + set_goals(&mut app, &[(ItemKind::Wood, 1000)]);
783 958 put_wood(&mut app, UVec2::new(5, 5), 20);
784 959 let colonist = spawn_colonist(&mut app, UVec2::new(2, 5));
785 960 app.world_mut().write_message(Order {
@@ -796,6 +971,88 @@ mod tests {
796 971 ));
797 972 }
798 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 +
799 1056 #[test]
800 1057 fn colonists_chop_from_a_neighbouring_tile() {
801 1058 let tree = UVec2::new(8, 5);

src/selection.rs +60 -28

@@ -5,7 +5,7 @@ use crate::{
5 5 camera::CameraController,
6 6 colonist::{Colonist, Path},
7 7 items::{Carrying, Item, ItemKind, stock},
8 - jobs::{CHOP_TICKS, ColonyGoals, CurrentJob, Job, Order, Task},
8 + jobs::{ColonyGoals, CurrentJob, EXPAND_TICKS, Job, Order, Task, WorkKind},
9 9 sim::{SimSpeed, SimTick, format_clock},
10 10 ui::{HideableUi, PointerOverUi},
11 11 world::{TILE_SIZE, TilePos, WorldMap},
@@ -158,16 +158,13 @@ fn draw_highlights(
158 158 colonists: Query<(Entity, &Transform, &Path, &CurrentJob), With<Colonist>>,
159 159 ) {
160 160 for (_, transform, _, job) in &colonists {
161 - if let Some(Job {
162 - task: Task::Chop { progress, .. },
163 - ..
164 - }) = job.0
165 - && progress > 0
161 + if let Some(fraction) = job.0.and_then(|j| progress_fraction(j.task))
162 + && fraction > 0.0
166 163 {
167 164 let left =
168 165 transform.translation.truncate() + Vec2::new(-TILE_SIZE * 0.4, TILE_SIZE * 0.6);
169 166 let full = TILE_SIZE * 0.8;
170 - let done = full * progress as f32 / CHOP_TICKS as f32;
167 + let done = full * fraction;
171 168 gizmos.line_2d(left, left + Vec2::X * full, PROGRESS_BACK_COLOR);
172 169 gizmos.line_2d(left, left + Vec2::X * done, PROGRESS_COLOR);
173 170 }
@@ -193,12 +190,12 @@ fn draw_highlights(
193 190 return;
194 191 };
195 192 if let Some(Job {
196 - task: Task::Chop { tree, .. },
193 + task: Task::Work { site, .. } | Task::ExpandStockpile { site, .. },
197 194 ..
198 195 }) = job.0
199 196 {
200 197 gizmos.rect_2d(
201 - WorldMap::tile_center(tree),
198 + WorldMap::tile_center(site),
202 199 Vec2::splat(TILE_SIZE),
203 200 PATH_COLOR,
204 201 );
@@ -256,17 +253,17 @@ fn update_tile_info(
256 253 let clock = format_clock(*tick);
257 254 let speed = describe_speed(*speed, time.is_paused());
258 255 let carried: Vec<Item> = colonists.iter().filter_map(|c| c.4.0).collect();
259 - let wood = stock(
260 - items.iter().map(|(item, _)| item).chain(carried.iter()),
261 - ItemKind::Wood,
262 - );
263 - let stored = stock(
264 - items
265 - .iter()
266 - .filter(|(_, pos)| zones.is_stockpile(pos.0))
267 - .map(|(item, _)| item),
268 - ItemKind::Wood,
269 - );
256 + let materials = ItemKind::ALL
257 + .iter()
258 + .map(|&kind| {
259 + let have = stock(
260 + items.iter().map(|(item, _)| item).chain(carried.iter()),
261 + kind,
262 + );
263 + format!("{kind:?} {have}/{}", goals.goal(kind))
264 + })
265 + .collect::<Vec<_>>()
266 + .join(" ");
270 267 let hovered_zone = match cursor.tile {
271 268 Some(tile) if zones.is_stockpile(tile) => ", Stockpile",
272 269 _ => "",
@@ -282,9 +279,8 @@ fn update_tile_info(
282 279 ""
283 280 };
284 281 let info = format!(
285 - "{clock} {speed}\nSeed: {}\nWood: {wood} / {} ({stored} stored){tool_hint}\nHover: {}{hovered_zone}{hovered_items}\nSelected: {selected}",
282 + "{clock} {speed}\nSeed: {}\n{materials}{tool_hint}\nHover: {}{hovered_zone}{hovered_items}\nSelected: {selected}",
286 283 seed.0,
287 - goals.wood,
288 284 describe_tile(&map, cursor.tile)
289 285 );
290 286 if text.0 != info {
@@ -314,6 +310,18 @@ pub fn describe_speed(speed: SimSpeed, paused: bool) -> String {
314 310 }
315 311 }
316 312
313 +pub fn progress_fraction(task: Task) -> Option<f32> {
314 + match task {
315 + Task::Work { kind, progress, .. } => Some(progress as f32 / kind.ticks() as f32),
316 + Task::ExpandStockpile { progress, .. } => Some(progress as f32 / EXPAND_TICKS as f32),
317 + _ => None,
318 + }
319 +}
320 +
321 +fn percent(task: Task) -> u32 {
322 + (progress_fraction(task).unwrap_or(0.0) * 100.0).round() as u32
323 +}
324 +
317 325 pub fn describe_colonist(
318 326 colonist: &Colonist,
319 327 tile: UVec2,
@@ -325,9 +333,32 @@ pub fn describe_colonist(
325 333 (None, 0) => "idle".to_string(),
326 334 (None, _) => "wandering".to_string(),
327 335 (Some(Task::MoveTo(_)), n) => format!("moving, {n} steps left"),
328 - (Some(Task::Chop { .. }), n) if n > 0 => "walking to a tree".to_string(),
329 - (Some(Task::Chop { progress, .. }), _) => {
330 - format!("chopping a tree, {}%", progress * 100 / CHOP_TICKS)
336 + (
337 + Some(Task::Work {
338 + kind: WorkKind::Chop,
339 + ..
340 + }),
341 + n,
342 + ) if n > 0 => "walking to a tree".to_string(),
343 + (
344 + Some(Task::Work {
345 + kind: WorkKind::Mine,
346 + ..
347 + }),
348 + n,
349 + ) if n > 0 => "walking to rock".to_string(),
350 + (Some(task @ Task::Work { kind, .. }), _) => {
351 + let verb = match kind {
352 + WorkKind::Chop => "chopping a tree",
353 + WorkKind::Mine => "mining",
354 + };
355 + format!("{verb}, {}%", percent(task))
356 + }
357 + (Some(Task::ExpandStockpile { .. }), n) if n > 0 => {
358 + "going to expand the stockpile".to_string()
359 + }
360 + (Some(task @ Task::ExpandStockpile { .. }), _) => {
361 + format!("expanding the stockpile, {}%", percent(task))
331 362 }
332 363 (
333 364 Some(Task::Haul {
@@ -533,9 +564,10 @@ mod tests {
533 564 "Ada at (1, 2), moving, 3 steps left (ordered)"
534 565 );
535 566 let chop = Job {
536 - task: Task::Chop {
537 - tree: UVec2::ZERO,
538 - progress: CHOP_TICKS / 2,
567 + task: Task::Work {
568 + site: UVec2::ZERO,
569 + kind: WorkKind::Chop,
570 + progress: WorkKind::Chop.ticks() / 2,
539 571 },
540 572 ordered: false,
541 573 };

src/world.rs +5 -2

@@ -14,10 +14,11 @@ pub enum Terrain {
14 14 Stone,
15 15 IronOre,
16 16 GoldOre,
17 + Gravel,
17 18 }
18 19
19 20 impl Terrain {
20 - pub const ALL: [Terrain; 8] = [
21 + pub const ALL: [Terrain; 9] = [
21 22 Terrain::Grass,
22 23 Terrain::Soil,
23 24 Terrain::Sand,
@@ -26,6 +27,7 @@ impl Terrain {
26 27 Terrain::Stone,
27 28 Terrain::IronOre,
28 29 Terrain::GoldOre,
30 + Terrain::Gravel,
29 31 ];
30 32
31 33 pub fn index(self) -> u16 {
@@ -35,7 +37,7 @@ impl Terrain {
35 37 pub fn move_cost(self) -> Option<u32> {
36 38 match self {
37 39 Terrain::Grass | Terrain::Soil => Some(100),
38 - Terrain::Sand => Some(130),
40 + Terrain::Sand | Terrain::Gravel => Some(130),
39 41 Terrain::ShallowWater => Some(300),
40 42 Terrain::DeepWater | Terrain::Stone | Terrain::IronOre | Terrain::GoldOre => None,
41 43 }
@@ -57,6 +59,7 @@ impl Terrain {
57 59 Terrain::ShallowWater => Srgba::rgb_u8(72, 132, 184),
58 60 Terrain::DeepWater => Srgba::rgb_u8(36, 76, 140),
59 61 Terrain::Stone | Terrain::IronOre | Terrain::GoldOre => Srgba::rgb_u8(118, 118, 124),
62 + Terrain::Gravel => Srgba::rgb_u8(150, 146, 138),
60 63 }
61 64 }
62 65

src/worldgen.rs +1 -1

@@ -369,7 +369,7 @@ mod tests {
369 369 .map(|&t| share(&map, t))
370 370 .sum();
371 371 assert!((rock - STONE_SHARE).abs() < 0.02);
372 - for terrain in Terrain::ALL {
372 + for terrain in Terrain::ALL.into_iter().filter(|&t| t != Terrain::Gravel) {
373 373 assert!(share(&map, terrain) > 0.0, "seed {seed} lacks {terrain:?}");
374 374 }
375 375 }

src/zones.rs +92 -3

@@ -47,10 +47,22 @@ impl Zones {
47 47 self.stockpile.iter().copied()
48 48 }
49 49
50 + pub fn has_stockpile(&self) -> bool {
51 + !self.stockpile.is_empty()
52 + }
53 +
54 + pub fn empty_stockpile_tiles(&self, occupied: &HashSet<UVec2>) -> usize {
55 + self.stockpile.difference(occupied).count()
56 + }
57 +
50 58 pub fn add_stockpile(&mut self, map: &WorldMap, a: IVec2, b: IVec2) {
51 - for tile in rect_tiles(a, b) {
52 - if can_zone(map, tile) {
53 - self.stockpile.insert(tile.as_uvec2());
59 + self.add_tiles(map, rect_tiles(a, b).filter_map(|t| t.try_into().ok()));
60 + }
61 +
62 + pub fn add_tiles(&mut self, map: &WorldMap, tiles: impl IntoIterator<Item = UVec2>) {
63 + for tile in tiles {
64 + if can_zone(map, tile.as_ivec2()) {
65 + self.stockpile.insert(tile);
54 66 }
55 67 }
56 68 }
@@ -64,6 +76,46 @@ impl Zones {
64 76 }
65 77 }
66 78
79 +pub fn plan_expansion(map: &WorldMap, zones: &Zones, count: usize) -> Vec<UVec2> {
80 + if zones.stockpile.is_empty() {
81 + return Vec::new();
82 + }
83 + let centroid =
84 + zones.stockpile.iter().map(|t| t.as_vec2()).sum::<Vec2>() / zones.stockpile.len() as f32;
85 + let preference = |t: UVec2| {
86 + (
87 + map.plant(t.as_ivec2()).is_some(),
88 + t.as_vec2().distance_squared(centroid),
89 + t.y,
90 + t.x,
91 + )
92 + };
93 + let mut planned: Vec<UVec2> = Vec::new();
94 + while planned.len() < count {
95 + let taken = |t: UVec2| zones.is_stockpile(t) || planned.contains(&t);
96 + let best = zones
97 + .stockpile
98 + .iter()
99 + .chain(planned.iter())
100 + .flat_map(|t| {
101 + [IVec2::X, IVec2::NEG_X, IVec2::Y, IVec2::NEG_Y].map(|d| t.as_ivec2() + d)
102 + })
103 + .filter(|&t| can_zone(map, t) && !taken(t.as_uvec2()))
104 + .map(|t| t.as_uvec2())
105 + .min_by(|&a, &b| {
106 + let (ka, kb) = (preference(a), preference(b));
107 + ka.0.cmp(&kb.0)
108 + .then(ka.1.total_cmp(&kb.1))
109 + .then((ka.2, ka.3).cmp(&(kb.2, kb.3)))
110 + });
111 + match best {
112 + Some(tile) => planned.push(tile),
113 + None => break,
114 + }
115 + }
116 + planned
117 +}
118 +
67 119 pub fn can_zone(map: &WorldMap, tile: IVec2) -> bool {
68 120 map.move_cost(tile).is_some() && !map.get(tile).is_some_and(|t| t.is_water())
69 121 }
@@ -166,6 +218,43 @@ mod tests {
166 218 }
167 219
168 220 #[test]
221 + fn expansion_grows_compactly_around_the_stockpile() {
222 + let map = WorldMap::filled(UVec2::splat(10), Terrain::Grass);
223 + let mut zones = Zones::default();
224 + zones.add_stockpile(&map, IVec2::new(4, 4), IVec2::new(5, 5));
225 + let plan = plan_expansion(&map, &zones, 8);
226 + assert_eq!(plan.len(), 8);
227 + assert!(plan.iter().all(|t| !zones.is_stockpile(*t)));
228 + let beyond_one_ring = plan_expansion(&map, &zones, 20);
229 + assert_eq!(beyond_one_ring.len(), 20);
230 + assert!(
231 + plan.iter()
232 + .all(|t| (3..=6).contains(&t.x) && (3..=6).contains(&t.y))
233 + );
234 + }
235 +
236 + // Trees can be zoned, but stockpiling on open ground keeps forests intact and avoids
237 + // colonists cutting trees just to make room, so expansion only takes trees as a last resort.
238 + #[test]
239 + fn expansion_prefers_open_ground_over_trees_and_skips_unzonable_tiles() {
240 + let mut map = WorldMap::filled(UVec2::splat(10), Terrain::Grass);
241 + map.set(UVec2::new(3, 4), Terrain::Stone);
242 + map.set_plant(UVec2::new(5, 4), Some(crate::world::Plant::Tree));
243 + let mut zones = Zones::default();
244 + zones.add_stockpile(&map, IVec2::new(4, 4), IVec2::new(4, 4));
245 + let plan = plan_expansion(&map, &zones, 2);
246 + assert_eq!(plan.len(), 2);
247 + assert!(!plan.contains(&UVec2::new(3, 4)));
248 + assert!(!plan.contains(&UVec2::new(5, 4)));
249 + }
250 +
251 + #[test]
252 + fn no_expansion_without_a_stockpile() {
253 + let map = WorldMap::filled(UVec2::splat(10), Terrain::Grass);
254 + assert!(plan_expansion(&map, &Zones::default(), 8).is_empty());
255 + }
256 +
257 + #[test]
169 258 fn removing_clears_a_rectangle() {
170 259 let map = WorldMap::filled(UVec2::new(4, 4), Terrain::Grass);
171 260 let mut zones = Zones::default();