Add food spoilage
- 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 | |
| src/items.rs | +30 -31 | |
| src/jobs/mod.rs | +7 -28 | |
| src/jobs/tests.rs | +4 -8 | |
| src/selection.rs | +1 -4 |
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%" |