Add food spoilage

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-24 20:20 UTC
commit
e45f5128832f5073335d2a0f523327c1dad6bd65
parent
d98ebfd088
tree
browse at this commit

5 files changed +158 -75

src/food.rs +116 -4

@@ -2,11 +2,11 @@ use bevy::prelude::*;
2 2
3 3 use crate::{
4 4 colonist::{Colonist, Path},
5 - items::{Item, ItemKind},
5 + items::{Carrying, Item, ItemKind},
6 6 jobs::{CurrentJob, Reservations, Target},
7 7 needs::is_sleeping,
8 8 pathfinding::nearest_tile,
9 - sim::{SimSystems, TICKS_PER_HOUR},
9 + sim::{SimSystems, SimTick, TICKS_PER_HOUR},
10 10 world::{TilePos, WorldMap},
11 11 };
12 12
@@ -14,7 +14,10 @@ pub struct FoodPlugin;
14 14
15 15 impl Plugin for FoodPlugin {
16 16 fn build(&self, app: &mut App) {
17 - app.add_systems(FixedUpdate, update_hunger.in_set(SimSystems::Act));
17 + app.add_systems(
18 + FixedUpdate,
19 + (update_hunger, spoil_food).in_set(SimSystems::Act),
20 + );
18 21 }
19 22 }
20 23
@@ -23,6 +26,30 @@ pub const SLEEPING_HUNGER_FACTOR: f32 = 0.5;
23 26 pub const HUNGRY: f32 = 0.35;
24 27 pub const STARVING: f32 = 0.05;
25 28 pub const EAT_TICKS: u32 = 90;
29 +pub const ROOFED_SPOIL_FACTOR: f32 = 0.5;
30 +const SPOIL_INTERVAL_TICKS: u64 = 60;
31 +
32 +pub fn shelf_life_hours(kind: ItemKind) -> Option<f32> {
33 + match kind {
34 + ItemKind::Meat => Some(48.0),
35 + ItemKind::Berries => Some(72.0),
36 + ItemKind::Meal => Some(96.0),
37 + ItemKind::Vegetables => Some(120.0),
38 + _ => None,
39 + }
40 +}
41 +
42 +pub fn spoilage(kind: ItemKind, roofed: bool, ticks: f32) -> f32 {
43 + let Some(hours) = shelf_life_hours(kind) else {
44 + return 0.0;
45 + };
46 + let rate = ticks / (hours * TICKS_PER_HOUR as f32);
47 + if roofed {
48 + rate * ROOFED_SPOIL_FACTOR
49 + } else {
50 + rate
51 + }
52 +}
26 53 const FOOD_SEARCH_TILES: usize = 15000;
27 54
28 55 #[derive(Component, Debug, Clone, Copy, PartialEq)]
@@ -64,6 +91,33 @@ pub fn find_food(
64 91 })
65 92 }
66 93
94 +pub fn spoil_food(
95 + mut commands: Commands,
96 + tick: Res<SimTick>,
97 + map: Res<WorldMap>,
98 + mut items: Query<(Entity, &mut Item, &TilePos)>,
99 + mut carried: Query<&mut Carrying>,
100 +) {
101 + if !tick.0.is_multiple_of(SPOIL_INTERVAL_TICKS) {
102 + return;
103 + }
104 + let ticks = SPOIL_INTERVAL_TICKS as f32;
105 + for (entity, mut item, pos) in &mut items {
106 + item.spoil += spoilage(item.kind, map.is_roofed(pos.0.as_ivec2()), ticks);
107 + if item.spoil >= 1.0 {
108 + commands.entity(entity).despawn();
109 + }
110 + }
111 + for mut carrying in &mut carried {
112 + if let Some(load) = &mut carrying.0 {
113 + load.spoil += spoilage(load.kind, false, ticks);
114 + if load.spoil >= 1.0 {
115 + carrying.0 = None;
116 + }
117 + }
118 + }
119 +}
120 +
67 121 pub fn update_hunger(
68 122 mut colonists: Query<(&mut Hunger, &CurrentJob, &TilePos, &Path), With<Colonist>>,
69 123 ) {
@@ -83,6 +137,64 @@ mod tests {
83 137 use crate::world::Terrain;
84 138
85 139 #[test]
140 + fn food_spoils_slower_under_a_roof_and_materials_never() {
141 + let day = 24.0 * TICKS_PER_HOUR as f32;
142 + assert_eq!(spoilage(ItemKind::Meat, false, 2.0 * day), 1.0);
143 + assert_eq!(spoilage(ItemKind::Meat, true, 2.0 * day), 0.5);
144 + assert!(
145 + spoilage(ItemKind::Berries, false, day) > spoilage(ItemKind::Vegetables, false, day)
146 + );
147 + assert_eq!(spoilage(ItemKind::Wood, false, 100.0 * day), 0.0);
148 + }
149 +
150 + #[test]
151 + fn rotten_food_disappears() {
152 + let mut map = WorldMap::filled(UVec2::splat(4), Terrain::Grass);
153 + map.set_roof(UVec2::ONE, true);
154 + let mut app = App::new();
155 + app.insert_resource(map)
156 + .init_resource::<SimTick>()
157 + .add_systems(Update, spoil_food);
158 + let nearly_rotten = Item {
159 + spoil: 0.9999,
160 + ..Item::new(ItemKind::Meat, 5)
161 + };
162 + let outside = app
163 + .world_mut()
164 + .spawn((nearly_rotten, TilePos(UVec2::ZERO)))
165 + .id();
166 + let fresh = app
167 + .world_mut()
168 + .spawn((Item::new(ItemKind::Meat, 5), TilePos(UVec2::ONE)))
169 + .id();
170 + let wood = app
171 + .world_mut()
172 + .spawn((
173 + Item {
174 + spoil: 0.9999,
175 + ..Item::new(ItemKind::Wood, 5)
176 + },
177 + TilePos(UVec2::ZERO),
178 + ))
179 + .id();
180 + app.update();
181 + assert!(app.world().get_entity(outside).is_err());
182 + assert!(app.world().get_entity(fresh).is_ok());
183 + assert!(app.world().get_entity(wood).is_ok());
184 + }
185 +
186 + #[test]
187 + fn merged_stacks_average_their_freshness() {
188 + let mut stack = Item {
189 + spoil: 0.5,
190 + ..Item::new(ItemKind::Berries, 10)
191 + };
192 + stack.merge(Item::new(ItemKind::Berries, 10));
193 + assert_eq!(stack.count, 20);
194 + assert_eq!(stack.spoil, 0.25);
195 + }
196 +
197 + #[test]
86 198 fn portions_fill_a_colonist_up() {
87 199 assert_eq!(portion(ItemKind::Meal, 0.2), 1);
88 200 assert_eq!(portion(ItemKind::Berries, 0.3), 7);
@@ -93,7 +205,7 @@ mod tests {
93 205 fn colonists_prefer_meals_over_raw_food() {
94 206 let map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
95 207 let reservations = Reservations::default();
96 - let food = |kind, x| (Item { kind, count: 5 }, TilePos(UVec2::new(x, 0)));
208 + let food = |kind, x| (Item::new(kind, 5), TilePos(UVec2::new(x, 0)));
97 209 let items = [
98 210 food(ItemKind::Berries, 1),
99 211 food(ItemKind::Meal, 15),

src/items.rs +30 -31

@@ -78,6 +78,27 @@ impl ItemKind {
78 78 pub struct Item {
79 79 pub kind: ItemKind,
80 80 pub count: u32,
81 + pub spoil: f32,
82 +}
83 +
84 +impl Item {
85 + pub fn new(kind: ItemKind, count: u32) -> Self {
86 + Self {
87 + kind,
88 + count,
89 + spoil: 0.0,
90 + }
91 + }
92 +
93 + pub fn merge(&mut self, other: Item) {
94 + let total = (self.count + other.count).max(1) as f32;
95 + self.spoil = (self.spoil * self.count as f32 + other.spoil * other.count as f32) / total;
96 + self.count += other.count;
97 + }
98 +
99 + pub fn with_count(self, count: u32) -> Self {
100 + Self { count, ..self }
101 + }
81 102 }
82 103
83 104 #[derive(Component, Default, Debug, Clone, Copy, PartialEq)]
@@ -164,19 +185,19 @@ pub fn drop_items(
164 185 items: &mut ItemQuery,
165 186 assets: &ItemAssets,
166 187 tile: UVec2,
167 - kind: ItemKind,
168 - count: u32,
188 + dropped: Item,
169 189 ) {
190 + let kind = dropped.kind;
170 191 if let Some((_, mut item, _)) = items
171 192 .iter_mut()
172 193 .find(|(_, item, pos)| pos.0 == tile && item.kind == kind)
173 194 {
174 - item.count += count;
195 + item.merge(dropped);
175 196 return;
176 197 }
177 198 commands.spawn((
178 199 Name::new(format!("{kind:?}")),
179 - Item { kind, count },
200 + dropped,
180 201 TilePos(tile),
181 202 Mesh2d(assets.mesh.clone()),
182 203 MeshMaterial2d(assets.material(kind)),
@@ -196,10 +217,7 @@ pub fn take_items(
196 217 if item.count == 0 {
197 218 commands.entity(entity).despawn();
198 219 }
199 - Some(Item {
200 - kind: item.kind,
201 - count: taken,
202 - })
220 + Some(item.with_count(taken))
203 221 }
204 222
205 223 pub fn room_for<'a>(
@@ -232,8 +250,7 @@ mod tests {
232 250 &mut items,
233 251 &assets,
234 252 UVec2::ONE,
235 - ItemKind::Wood,
236 - 5,
253 + Item::new(ItemKind::Wood, 5),
237 254 );
238 255 }
239 256
@@ -249,13 +266,7 @@ mod tests {
249 266 .iter(app.world())
250 267 .copied()
251 268 .collect();
252 - assert_eq!(
253 - items,
254 - vec![Item {
255 - kind: ItemKind::Wood,
256 - count: 10
257 - }]
258 - );
269 + assert_eq!(items, vec![Item::new(ItemKind::Wood, 10)]);
259 270 }
260 271
261 272 fn take_system(mut commands: Commands, mut items: ItemQuery) {
@@ -263,10 +274,7 @@ mod tests {
263 274 }
264 275
265 276 fn wood(count: u32) -> Item {
266 - Item {
267 - kind: ItemKind::Wood,
268 - count,
269 - }
277 + Item::new(ItemKind::Wood, count)
270 278 }
271 279
272 280 #[test]
@@ -297,16 +305,7 @@ mod tests {
297 305
298 306 #[test]
299 307 fn stock_sums_counts_of_one_kind() {
300 - let items = [
301 - Item {
302 - kind: ItemKind::Wood,
303 - count: 3,
304 - },
305 - Item {
306 - kind: ItemKind::Wood,
307 - count: 4,
308 - },
309 - ];
308 + let items = [Item::new(ItemKind::Wood, 3), Item::new(ItemKind::Wood, 4)];
310 309 assert_eq!(stock(items.iter(), ItemKind::Wood), 7);
311 310 }
312 311 }

src/jobs/mod.rs +7 -28

@@ -884,14 +884,7 @@ fn choose_jobs(
884 884 })
885 885 } else if let Some(pickup) = food {
886 886 if let Some(load) = carrying.0.take() {
887 - drop_items(
888 - &mut commands,
889 - &mut items,
890 - &item_assets,
891 - tile.0,
892 - load.kind,
893 - load.count,
894 - );
887 + drop_items(&mut commands, &mut items, &item_assets, tile.0, load);
895 888 }
896 889 Some(Task::Eat {
897 890 pickup: Some(pickup),
@@ -904,14 +897,7 @@ fn choose_jobs(
904 897 } else if let Some(load) = carrying.0 {
905 898 let storage = find_storage(&map, &zones, &reservations, refs, tile.0, load.kind);
906 899 if storage.is_none() {
907 - drop_items(
908 - &mut commands,
909 - &mut items,
910 - &item_assets,
911 - tile.0,
912 - load.kind,
913 - load.count,
914 - );
900 + drop_items(&mut commands, &mut items, &item_assets, tile.0, load);
915 901 carrying.0 = None;
916 902 }
917 903 storage.map(|to| Task::Haul { pickup: None, to })
@@ -1115,8 +1101,7 @@ fn run_jobs(
1115 1101 &mut items,
1116 1102 &item_assets,
1117 1103 tile.0,
1118 - ItemKind::Meal,
1119 - 1,
1104 + Item::new(ItemKind::Meal, 1),
1120 1105 );
1121 1106 Step::Done
1122 1107 }
@@ -1148,8 +1133,7 @@ fn run_jobs(
1148 1133 &mut items,
1149 1134 &item_assets,
1150 1135 prey,
1151 - ItemKind::Meat,
1152 - kind.meat(),
1136 + Item::new(ItemKind::Meat, kind.meat()),
1153 1137 );
1154 1138 Step::Done
1155 1139 }
@@ -1244,8 +1228,7 @@ fn run_jobs(
1244 1228 &mut items,
1245 1229 &item_assets,
1246 1230 site,
1247 - work.item,
1248 - work.count,
1231 + Item::new(work.item, work.count),
1249 1232 );
1250 1233 }
1251 1234 Step::Done
@@ -1304,10 +1287,7 @@ fn run_jobs(
1304 1287 .map_or(0, |load| load.count);
1305 1288 reservations.release(Target::Pickup(from));
1306 1289 let total = have + taken;
1307 - carrying.0 = (total > 0).then_some(Item {
1308 - kind: material,
1309 - count: total,
1310 - });
1290 + carrying.0 = (total > 0).then_some(Item::new(material, total));
1311 1291 let next = nearest_tile(&map, tile.0, SEARCH_TILES, |p| {
1312 1292 reservations.is_free(Target::Pickup(p))
1313 1293 && items.iter().any(|(_, item, pos)| {
@@ -1417,8 +1397,7 @@ fn run_jobs(
1417 1397 &mut items,
1418 1398 &item_assets,
1419 1399 to,
1420 - load.kind,
1421 - dropped,
1400 + load.with_count(dropped),
1422 1401 );
1423 1402 }
1424 1403 carrying.0 = (load.count > dropped).then_some(Item {

src/jobs/tests.rs +4 -8

@@ -87,13 +87,8 @@ fn items_at(app: &mut App) -> Vec<(UVec2, u32)> {
87 87 }
88 88
89 89 fn put_wood(app: &mut App, tile: UVec2, count: u32) {
90 - app.world_mut().spawn((
91 - Item {
92 - kind: ItemKind::Wood,
93 - count,
94 - },
95 - TilePos(tile),
96 - ));
90 + app.world_mut()
91 + .spawn((Item::new(ItemKind::Wood, count), TilePos(tile)));
97 92 }
98 93
99 94 fn set_goals(app: &mut App, goals: &[(ItemKind, u32)]) {
@@ -713,7 +708,8 @@ fn colonists_sleep_in_their_beds() {
713 708 }
714 709
715 710 fn put_items(app: &mut App, tile: UVec2, kind: ItemKind, count: u32) {
716 - app.world_mut().spawn((Item { kind, count }, TilePos(tile)));
711 + app.world_mut()
712 + .spawn((Item::new(kind, count), TilePos(tile)));
717 713 }
718 714
719 715 #[test]

src/selection.rs +1 -4

@@ -741,10 +741,7 @@ mod tests {
741 741 },
742 742 ordered: false,
743 743 };
744 - let meal = Item {
745 - kind: ItemKind::Meal,
746 - count: 1,
747 - };
744 + let meal = Item::new(ItemKind::Meal, 1);
748 745 assert_eq!(
749 746 describe_colonist(&view(Some(eat), 0, Some(meal))),
750 747 "Ada at (1, 2), eating, carrying 1 Meal, rest 100%, food 50%"