src/food.rs 6.5 K · 228 lines · raw · history

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