src/food.rs 6.7 K · 231 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::nearest_of,
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 items: &[(Item, TilePos)],
80 from: UVec2,
81 ) -> Option<UVec2> {
82 let tiles = |accept: fn(ItemKind) -> bool| -> HashSet<UVec2> {
83 items
84 .iter()
85 .filter(|(item, pos)| accept(item.kind) && reservations.is_free(Target::Pickup(pos.0)))
86 .map(|(_, pos)| pos.0)
87 .collect()
88 };
89 let search = |tiles: HashSet<UVec2>| nearest_of(map, from, FOOD_SEARCH_TILES, &tiles);
90 search(tiles(|k| k == ItemKind::Meal)).or_else(|| search(tiles(ItemKind::is_raw_food)))
91 }
92
93 pub fn spoil_food(
94 mut commands: Commands,
95 tick: Res<SimTick>,
96 map: Res<WorldMap>,
97 mut items: Query<(Entity, &mut Item, &TilePos)>,
98 mut carried: Query<&mut Carrying>,
99 ) {
100 if !tick.0.is_multiple_of(SPOIL_INTERVAL_TICKS) {
101 return;
102 }
103 let ticks = SPOIL_INTERVAL_TICKS as f32 * spoil_factor(outdoor_temperature(*tick));
104 for (entity, mut item, pos) in &mut items {
105 item.spoil += spoilage(item.kind, map.is_roofed(pos.0.as_ivec2()), ticks);
106 if item.spoil >= 1.0 {
107 commands.entity(entity).despawn();
108 }
109 }
110 for mut carrying in &mut carried {
111 if let Some(load) = &mut carrying.0 {
112 load.spoil += spoilage(load.kind, false, ticks);
113 if load.spoil >= 1.0 {
114 carrying.0 = None;
115 }
116 }
117 }
118 }
119
120 pub fn update_hunger(
121 mut colonists: Query<(&mut Hunger, &CurrentJob, &TilePos, &Path, &Warmth), With<Colonist>>,
122 ) {
123 for (mut hunger, job, tile, path, warmth) in &mut colonists {
124 let rate = if is_sleeping(job, tile, path) {
125 HUNGER_PER_TICK * SLEEPING_HUNGER_FACTOR
126 } else {
127 HUNGER_PER_TICK
128 };
129 let rate = if warmth.cold() {
130 rate * COLD_HUNGER_FACTOR
131 } else {
132 rate
133 };
134 hunger.0 = (hunger.0 - rate).max(0.0);
135 }
136 }
137
138 #[cfg(test)]
139 mod tests {
140 use super::*;
141 use crate::world::Terrain;
142
143 #[test]
144 fn food_spoils_slower_under_a_roof_and_materials_never() {
145 let day = 24.0 * TICKS_PER_HOUR as f32;
146 assert_eq!(spoilage(ItemKind::Meat, false, 2.0 * day), 1.0);
147 assert_eq!(spoilage(ItemKind::Meat, true, 2.0 * day), 0.5);
148 assert!(
149 spoilage(ItemKind::Berries, false, day) > spoilage(ItemKind::Vegetables, false, day)
150 );
151 assert_eq!(spoilage(ItemKind::Wood, false, 100.0 * day), 0.0);
152 }
153
154 #[test]
155 fn rotten_food_disappears() {
156 let mut map = WorldMap::filled(UVec2::splat(4), Terrain::Grass);
157 map.set_roof(UVec2::ONE, true);
158 let mut app = App::new();
159 app.insert_resource(map)
160 .init_resource::<SimTick>()
161 .add_systems(Update, spoil_food);
162 let nearly_rotten = Item {
163 spoil: 0.9999,
164 ..Item::new(ItemKind::Meat, 5)
165 };
166 let outside = app
167 .world_mut()
168 .spawn((nearly_rotten, TilePos(UVec2::ZERO)))
169 .id();
170 let fresh = app
171 .world_mut()
172 .spawn((Item::new(ItemKind::Meat, 5), TilePos(UVec2::ONE)))
173 .id();
174 let wood = app
175 .world_mut()
176 .spawn((
177 Item {
178 spoil: 0.9999,
179 ..Item::new(ItemKind::Wood, 5)
180 },
181 TilePos(UVec2::ZERO),
182 ))
183 .id();
184 app.update();
185 assert!(app.world().get_entity(outside).is_err());
186 assert!(app.world().get_entity(fresh).is_ok());
187 assert!(app.world().get_entity(wood).is_ok());
188 }
189
190 #[test]
191 fn merged_stacks_average_their_freshness() {
192 let mut stack = Item {
193 spoil: 0.5,
194 ..Item::new(ItemKind::Berries, 10)
195 };
196 stack.merge(Item::new(ItemKind::Berries, 10));
197 assert_eq!(stack.count, 20);
198 assert_eq!(stack.spoil, 0.25);
199 }
200
201 #[test]
202 fn portions_fill_a_colonist_up() {
203 assert_eq!(portion(ItemKind::Meal, 0.2), 1);
204 assert_eq!(portion(ItemKind::Berries, 0.3), 7);
205 assert_eq!(portion(ItemKind::Berries, 0.99), 1);
206 }
207
208 #[test]
209 fn colonists_prefer_meals_over_raw_food() {
210 let map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
211 let reservations = Reservations::default();
212 let food = |kind, x| (Item::new(kind, 5), TilePos(UVec2::new(x, 0)));
213 let items = [
214 food(ItemKind::Berries, 1),
215 food(ItemKind::Meal, 15),
216 food(ItemKind::Wood, 0),
217 ];
218 assert_eq!(
219 find_food(&map, &reservations, &items, UVec2::ZERO),
220 Some(UVec2::new(15, 0))
221 );
222 assert_eq!(
223 find_food(&map, &reservations, &items[..1], UVec2::ZERO),
224 Some(UVec2::new(1, 0))
225 );
226 assert_eq!(
227 find_food(&map, &reservations, &items[2..], UVec2::ZERO),
228 None
229 );
230 }
231 }