Add cooking meals at campfires

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-24 11:14 UTC
commit
8a7e338052d1d1d7bf9274c5615012230c8a12c8
parent
85be05d307
tree
browse at this commit

3 files changed +198 -7

src/jobs/mod.rs +146 -6

@@ -114,6 +114,11 @@ pub enum Task {
114 114 kind: AnimalKind,
115 115 progress: u32,
116 116 },
117 + Cook {
118 + pickup: Option<UVec2>,
119 + station: UVec2,
120 + progress: u32,
121 + },
117 122 }
118 123
119 124 pub fn work_rate(light: &LightMap, tile: UVec2) -> u32 {
@@ -226,6 +231,7 @@ pub enum Target {
226 231 StockpileExpansion,
227 232 SleepSpot(UVec2),
228 233 Animal(Entity),
234 + Station(UVec2),
229 235 }
230 236
231 237 #[derive(Resource, Default, Debug)]
@@ -367,6 +373,14 @@ fn reserve_task(reservations: &mut Reservations, task: Task, by: Entity) {
367 373 }
368 374 }
369 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 + }
370 384 Task::Build { site, pickup, .. } => {
371 385 reservations.reserve(Target::Site(site), by);
372 386 if let Some(pickup) = pickup {
@@ -656,6 +670,51 @@ impl BuildContext<'_> {
656 670 }
657 671 }
658 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 +
659 718 pub fn find_lighting(ctx: &BuildContext, from: UVec2) -> Option<Task> {
660 719 let home = ctx.zones.home(from);
661 720 let (kind, site) = if ctx.campfire_needed(home) {
@@ -764,8 +823,10 @@ fn choose_jobs(
764 823 .collect();
765 824 let mut pending = on_the_way(&map, colonists.iter().map(|c| c.3.0));
766 825 for job in colonists.iter().filter_map(|c| c.3.0) {
767 - if let Task::Hunt { kind, .. } = job.task {
768 - *pending.entry(ItemKind::Meat).or_insert(0) += kind.meat();
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 + _ => {}
769 830 }
770 831 }
771 832 let hour = hour_of_day(*tick);
@@ -856,6 +917,15 @@ fn choose_jobs(
856 917 };
857 918 find_expansion(&map, &zones, &reservations, &snapshot, &blueprints)
858 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 + })
859 929 .or_else(|| find_work(&map, &reservations, tile.0, wanted))
860 930 .or_else(|| {
861 931 wanted(ItemKind::Meat)
@@ -873,8 +943,12 @@ fn choose_jobs(
873 943 {
874 944 *pending.entry(work.item).or_insert(0) += work.count;
875 945 }
876 - if let Task::Hunt { kind, .. } = task {
877 - *pending.entry(ItemKind::Meat).or_insert(0) += kind.meat();
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 + _ => {}
878 952 }
879 953 if let Task::Build {
880 954 site,
@@ -933,7 +1007,7 @@ fn run_jobs(
933 1007 tick: Res<SimTick>,
934 1008 mut blueprints: ResMut<Blueprints>,
935 1009 mut items: ItemQuery,
936 - structures: Query<&TilePos, (With<Structure>, Without<Colonist>)>,
1010 + structures: Query<(&TilePos, &Structure), Without<Colonist>>,
937 1011 animals: Query<&TilePos, (With<Animal>, Without<Colonist>)>,
938 1012 mut changed: MessageWriter<TileChanged>,
939 1013 mut colonists: Query<
@@ -963,6 +1037,72 @@ fn run_jobs(
963 1037 }
964 1038 let step = match &mut job.task {
965 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 + }
966 1106 Task::Hunt {
967 1107 animal,
968 1108 kind,
@@ -1127,7 +1267,7 @@ fn run_jobs(
1127 1267 _ => blueprints.get(site) == Some(kind),
1128 1268 };
1129 1269 let blocked = map.plant(site.as_ivec2()).is_some()
1130 - || (kind != BuildKind::Roof && structures.iter().any(|pos| pos.0 == site));
1270 + || (kind != BuildKind::Roof && structures.iter().any(|(pos, _)| pos.0 == site));
1131 1271 let (material, cost) = kind.cost();
1132 1272 if !ours || blocked || !planned {
1133 1273 Step::Done

src/jobs/tests.rs +42 -0

@@ -937,3 +937,45 @@ fn ripe_crops_are_harvested_into_vegetables_and_resown() {
937 937 assert_eq!(map.plant(farm[0].as_ivec2()), Some(Plant::Crop));
938 938 assert!(map.growth(farm[0].as_ivec2()) < 0.1);
939 939 }
940 +
941 +#[test]
942 +fn colonists_cook_raw_food_into_meals_at_a_campfire() {
943 + let mut app = test_app(map_with_trees(&[]));
944 + set_goals(&mut app, &[(ItemKind::Meal, 2)]);
945 + put_beds(&mut app, 1);
946 + put_campfire(&mut app, UVec2::new(10, 10));
947 + put_items(&mut app, UVec2::new(5, 5), ItemKind::Meat, 12);
948 + spawn_colonist(&mut app, UVec2::new(2, 2));
949 + run_ticks(&mut app, 60 * 20);
950 + assert_eq!(items_of(&mut app, ItemKind::Meal), 2);
951 + assert_eq!(items_of(&mut app, ItemKind::Meat), 0);
952 +}
953 +
954 +#[test]
955 +fn colonists_light_a_fire_to_cook_on() {
956 + let mut app = test_app(map_with_trees(&[]));
957 + set_goals(&mut app, &[(ItemKind::Meal, 1)]);
958 + put_beds(&mut app, 1);
959 + put_items(&mut app, UVec2::new(5, 5), ItemKind::Berries, 6);
960 + put_items(&mut app, UVec2::new(6, 5), ItemKind::Wood, 10);
961 + spawn_colonist(&mut app, UVec2::new(2, 2));
962 + run_ticks(&mut app, 60 * 25);
963 + assert!(
964 + structures(&mut app)
965 + .iter()
966 + .any(|(_, kind)| *kind == StructureKind::Campfire)
967 + );
968 + assert_eq!(items_of(&mut app, ItemKind::Meal), 1);
969 +}
970 +
971 +#[test]
972 +fn no_cooking_without_demand_for_meals() {
973 + let mut app = test_app(map_with_trees(&[]));
974 + set_goals(&mut app, &[]);
975 + put_beds(&mut app, 1);
976 + put_campfire(&mut app, UVec2::new(10, 10));
977 + put_items(&mut app, UVec2::new(5, 5), ItemKind::Meat, 12);
978 + spawn_colonist(&mut app, UVec2::new(2, 2));
979 + run_ticks(&mut app, 60 * 10);
980 + assert_eq!(items_of(&mut app, ItemKind::Meal), 0);
981 +}

src/selection.rs +10 -1

@@ -8,7 +8,7 @@ use crate::{
8 8 colonist::{Colonist, Path},
9 9 food::Hunger,
10 10 items::{Carrying, Item, ItemKind, stock},
11 - jobs::{ColonyGoals, CurrentJob, EXPAND_WORK, Job, Order, Task, WorkKind},
11 + jobs::{COOK_WORK, ColonyGoals, CurrentJob, EXPAND_WORK, Job, Order, Task, WorkKind},
12 12 light::daylight,
13 13 needs::Rest,
14 14 sim::{SimSpeed, SimTick, TICKS_PER_HOUR, format_clock, hour_of_day},
@@ -394,6 +394,7 @@ pub fn progress_fraction(task: Task) -> Option<f32> {
394 394 Task::ExpandStockpile { progress, .. } => Some(progress as f32 / EXPAND_WORK as f32),
395 395 Task::Build { kind, progress, .. } => Some(progress as f32 / kind.work() as f32),
396 396 Task::Hunt { kind, progress, .. } => Some(progress as f32 / kind.hunt_work() as f32),
397 + Task::Cook { progress, .. } => Some(progress as f32 / COOK_WORK as f32),
397 398 _ => None,
398 399 }
399 400 }
@@ -485,6 +486,14 @@ pub fn describe_colonist(view: &ColonistView) -> String {
485 486 format!("hunting a {}, {}%", kind.name(), percent(task))
486 487 }
487 488 (Some(Task::Hunt { kind, .. }), _) => format!("stalking a {}", kind.name()),
489 + (
490 + Some(Task::Cook {
491 + pickup: Some(_), ..
492 + }),
493 + _,
494 + ) => "fetching ingredients".to_string(),
495 + (Some(Task::Cook { .. }), n) if n > 0 => "carrying ingredients to the fire".to_string(),
496 + (Some(task @ Task::Cook { .. }), _) => format!("cooking a meal, {}%", percent(task)),
488 497 (Some(task @ Task::Build { kind, .. }), _) => {
489 498 format!("building a {}, {}%", kind.name(), percent(task))
490 499 }