src/food.rs 7.1 K · 250 lines · raw · history

1 use std::collections::HashSet;
2
3 use bevy::prelude::*;
4
5 use crate::{
6 climate::{Warmth, outdoor_temperature, spoil_factor},
7 colonist::{Colonist, Path},
8 items::{Carrying, Item, ItemKind},
9 jobs::{CurrentJob, Reservations, Target},
10 needs::is_sleeping,
11 pathfinding::ColonyReach,
12 sim::{SimSystems, SimTick, TICKS_PER_HOUR},
13 world::{TilePos, WorldMap},
14 };
15
16 pub struct FoodPlugin;
17
18 impl Plugin for FoodPlugin {
19 fn build(&self, app: &mut App) {
20 app.add_systems(
21 FixedUpdate,
22 (update_hunger, spoil_food).in_set(SimSystems::Act),
23 );
24 }
25 }
26
27 pub const HUNGER_PER_TICK: f32 = 1.0 / (24.0 * TICKS_PER_HOUR as f32);
28 pub const SLEEPING_HUNGER_FACTOR: f32 = 0.5;
29 pub const HUNGRY: f32 = 0.35;
30 pub const COLD_HUNGER_FACTOR: f32 = 1.5;
31 pub const STARVING: f32 = 0.05;
32 pub const EAT_TICKS: u32 = 90;
33 pub const ROOFED_SPOIL_FACTOR: f32 = 0.5;
34 const SPOIL_INTERVAL_TICKS: u64 = 60;
35
36 pub fn shelf_life_hours(kind: ItemKind) -> Option<f32> {
37 match kind {
38 ItemKind::Meat | ItemKind::Carcass => Some(48.0),
39 ItemKind::Berries => Some(72.0),
40 ItemKind::Meal => Some(96.0),
41 ItemKind::Vegetables => Some(120.0),
42 _ => None,
43 }
44 }
45
46 pub fn spoilage(kind: ItemKind, roofed: bool, ticks: f32) -> f32 {
47 let Some(hours) = shelf_life_hours(kind) else {
48 return 0.0;
49 };
50 let rate = ticks / (hours * TICKS_PER_HOUR as f32);
51 if roofed {
52 rate * ROOFED_SPOIL_FACTOR
53 } else {
54 rate
55 }
56 }
57 const FOOD_SEARCH_TILES: usize = 15000;
58
59 #[derive(Component, Debug, Clone, Copy, PartialEq)]
60 pub struct Hunger(pub f32);
61
62 impl Default for Hunger {
63 fn default() -> Self {
64 Self(1.0)
65 }
66 }
67
68 pub fn wants_food(fed: f32) -> bool {
69 fed < HUNGRY
70 }
71
72 pub fn portion(kind: ItemKind, fed: f32) -> u32 {
73 ((1.0 - fed) / kind.nutrition()).ceil().max(1.0) as u32
74 }
75
76 pub fn find_food(
77 map: &WorldMap,
78 reservations: &Reservations,
79 reach: &ColonyReach,
80 items: &[(Item, TilePos)],
81 from: UVec2,
82 ) -> Option<UVec2> {
83 let tiles = |accept: fn(ItemKind) -> bool| -> HashSet<UVec2> {
84 items
85 .iter()
86 .filter(|(item, pos)| accept(item.kind) && reservations.is_free(Target::Pickup(pos.0)))
87 .map(|(_, pos)| pos.0)
88 .collect()
89 };
90 let search = |tiles: HashSet<UVec2>| reach.nearest(map, from, FOOD_SEARCH_TILES, &tiles);
91 search(tiles(|k| k == ItemKind::Meal)).or_else(|| search(tiles(ItemKind::is_raw_food)))
92 }
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 * spoil_factor(outdoor_temperature(*tick));
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
121 pub fn update_hunger(
122 mut colonists: Query<(&mut Hunger, &CurrentJob, &TilePos, &Path, &Warmth), With<Colonist>>,
123 ) {
124 for (mut hunger, job, tile, path, warmth) in &mut colonists {
125 let rate = if is_sleeping(job, tile, path) {
126 HUNGER_PER_TICK * SLEEPING_HUNGER_FACTOR
127 } else {
128 HUNGER_PER_TICK
129 };
130 let rate = if warmth.cold() {
131 rate * COLD_HUNGER_FACTOR
132 } else {
133 rate
134 };
135 hunger.0 = (hunger.0 - rate).max(0.0);
136 }
137 }
138
139 #[cfg(test)]
140 mod tests {
141 use super::*;
142 use crate::world::Terrain;
143
144 #[test]
145 fn food_spoils_slower_under_a_roof_and_materials_never() {
146 let day = 24.0 * TICKS_PER_HOUR as f32;
147 assert_eq!(spoilage(ItemKind::Meat, false, 2.0 * day), 1.0);
148 assert_eq!(spoilage(ItemKind::Meat, true, 2.0 * day), 0.5);
149 assert!(
150 spoilage(ItemKind::Berries, false, day) > spoilage(ItemKind::Vegetables, false, day)
151 );
152 assert_eq!(spoilage(ItemKind::Wood, false, 100.0 * day), 0.0);
153 }
154
155 #[test]
156 fn rotten_food_disappears() {
157 let mut map = WorldMap::filled(UVec2::splat(4), Terrain::Grass);
158 map.set_roof(UVec2::ONE, true);
159 let mut app = App::new();
160 app.insert_resource(map)
161 .init_resource::<SimTick>()
162 .add_systems(Update, spoil_food);
163 let nearly_rotten = Item {
164 spoil: 0.9999,
165 ..Item::new(ItemKind::Meat, 5)
166 };
167 let outside = app
168 .world_mut()
169 .spawn((nearly_rotten, TilePos(UVec2::ZERO)))
170 .id();
171 let fresh = app
172 .world_mut()
173 .spawn((Item::new(ItemKind::Meat, 5), TilePos(UVec2::ONE)))
174 .id();
175 let wood = app
176 .world_mut()
177 .spawn((
178 Item {
179 spoil: 0.9999,
180 ..Item::new(ItemKind::Wood, 5)
181 },
182 TilePos(UVec2::ZERO),
183 ))
184 .id();
185 app.update();
186 assert!(app.world().get_entity(outside).is_err());
187 assert!(app.world().get_entity(fresh).is_ok());
188 assert!(app.world().get_entity(wood).is_ok());
189 }
190
191 #[test]
192 fn merged_stacks_average_their_freshness() {
193 let mut stack = Item {
194 spoil: 0.5,
195 ..Item::new(ItemKind::Berries, 10)
196 };
197 stack.merge(Item::new(ItemKind::Berries, 10));
198 assert_eq!(stack.count, 20);
199 assert_eq!(stack.spoil, 0.25);
200 }
201
202 #[test]
203 fn portions_fill_a_colonist_up() {
204 assert_eq!(portion(ItemKind::Meal, 0.2), 1);
205 assert_eq!(portion(ItemKind::Berries, 0.3), 7);
206 assert_eq!(portion(ItemKind::Berries, 0.99), 1);
207 }
208
209 #[test]
210 fn colonists_prefer_meals_over_raw_food() {
211 let map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
212 let reservations = Reservations::default();
213 let food = |kind, x| (Item::new(kind, 5), TilePos(UVec2::new(x, 0)));
214 let items = [
215 food(ItemKind::Berries, 1),
216 food(ItemKind::Meal, 15),
217 food(ItemKind::Wood, 0),
218 ];
219 assert_eq!(
220 find_food(
221 &map,
222 &reservations,
223 &ColonyReach::default(),
224 &items,
225 UVec2::ZERO
226 ),
227 Some(UVec2::new(15, 0))
228 );
229 assert_eq!(
230 find_food(
231 &map,
232 &reservations,
233 &ColonyReach::default(),
234 &items[..1],
235 UVec2::ZERO
236 ),
237 Some(UVec2::new(1, 0))
238 );
239 assert_eq!(
240 find_food(
241 &map,
242 &reservations,
243 &ColonyReach::default(),
244 &items[2..],
245 UVec2::ZERO
246 ),
247 None
248 );
249 }
250 }