src/food.rs 6.4 K · 227 lines · raw · history

1 use bevy::prelude::*;
2
3 use crate::{
4 colonist::{Colonist, Path},
5 items::{Carrying, Item, ItemKind},
6 jobs::{CurrentJob, Reservations, Target},
7 needs::is_sleeping,
8 pathfinding::nearest_tile,
9 sim::{SimSystems, SimTick, TICKS_PER_HOUR},
10 world::{TilePos, WorldMap},
11 };
12
13 pub struct FoodPlugin;
14
15 impl Plugin for FoodPlugin {
16 fn build(&self, app: &mut App) {
17 app.add_systems(
18 FixedUpdate,
19 (update_hunger, spoil_food).in_set(SimSystems::Act),
20 );
21 }
22 }
23
24 pub const HUNGER_PER_TICK: f32 = 1.0 / (24.0 * TICKS_PER_HOUR as f32);
25 pub const SLEEPING_HUNGER_FACTOR: f32 = 0.5;
26 pub const HUNGRY: f32 = 0.35;
27 pub const STARVING: f32 = 0.05;
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 }
53 const FOOD_SEARCH_TILES: usize = 15000;
54
55 #[derive(Component, Debug, Clone, Copy, PartialEq)]
56 pub struct Hunger(pub f32);
57
58 impl Default for Hunger {
59 fn default() -> Self {
60 Self(1.0)
61 }
62 }
63
64 pub fn wants_food(fed: f32) -> bool {
65 fed < HUNGRY
66 }
67
68 pub fn portion(kind: ItemKind, fed: f32) -> u32 {
69 ((1.0 - fed) / kind.nutrition()).ceil().max(1.0) as u32
70 }
71
72 pub fn find_food(
73 map: &WorldMap,
74 reservations: &Reservations,
75 items: &[(Item, TilePos)],
76 from: UVec2,
77 ) -> Option<UVec2> {
78 let has = |p: UVec2, accept: fn(ItemKind) -> bool| {
79 reservations.is_free(Target::Pickup(p))
80 && items
81 .iter()
82 .any(|(item, pos)| pos.0 == p && accept(item.kind))
83 };
84 nearest_tile(map, from, FOOD_SEARCH_TILES, |p| {
85 has(p, |k| k == ItemKind::Meal)
86 })
87 .or_else(|| {
88 nearest_tile(map, from, FOOD_SEARCH_TILES, |p| {
89 has(p, ItemKind::is_raw_food)
90 })
91 })
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;
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), With<Colonist>>,
123 ) {
124 for (mut hunger, job, tile, path) 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 hunger.0 = (hunger.0 - rate).max(0.0);
131 }
132 }
133
134 #[cfg(test)]
135 mod tests {
136 use super::*;
137 use crate::world::Terrain;
138
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]
198 fn portions_fill_a_colonist_up() {
199 assert_eq!(portion(ItemKind::Meal, 0.2), 1);
200 assert_eq!(portion(ItemKind::Berries, 0.3), 7);
201 assert_eq!(portion(ItemKind::Berries, 0.99), 1);
202 }
203
204 #[test]
205 fn colonists_prefer_meals_over_raw_food() {
206 let map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
207 let reservations = Reservations::default();
208 let food = |kind, x| (Item::new(kind, 5), TilePos(UVec2::new(x, 0)));
209 let items = [
210 food(ItemKind::Berries, 1),
211 food(ItemKind::Meal, 15),
212 food(ItemKind::Wood, 0),
213 ];
214 assert_eq!(
215 find_food(&map, &reservations, &items, UVec2::ZERO),
216 Some(UVec2::new(15, 0))
217 );
218 assert_eq!(
219 find_food(&map, &reservations, &items[..1], UVec2::ZERO),
220 Some(UVec2::new(1, 0))
221 );
222 assert_eq!(
223 find_food(&map, &reservations, &items[2..], UVec2::ZERO),
224 None
225 );
226 }
227 }