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