use std::collections::HashSet; use bevy::prelude::*; use crate::{ climate::{Warmth, outdoor_temperature, spoil_factor}, colonist::{Colonist, Path}, items::{Carrying, Item, ItemKind}, jobs::{CurrentJob, Reservations, Target}, needs::is_sleeping, pathfinding::ColonyReach, sim::{SimSystems, SimTick, TICKS_PER_HOUR}, world::{TilePos, WorldMap}, }; pub struct FoodPlugin; impl Plugin for FoodPlugin { fn build(&self, app: &mut App) { app.add_systems( FixedUpdate, (update_hunger, spoil_food).in_set(SimSystems::Act), ); } } pub const HUNGER_PER_TICK: f32 = 1.0 / (24.0 * TICKS_PER_HOUR as f32); pub const SLEEPING_HUNGER_FACTOR: f32 = 0.5; pub const HUNGRY: f32 = 0.35; pub const COLD_HUNGER_FACTOR: f32 = 1.5; pub const STARVING: f32 = 0.05; pub const EAT_TICKS: u32 = 90; pub const ROOFED_SPOIL_FACTOR: f32 = 0.5; const SPOIL_INTERVAL_TICKS: u64 = 60; pub fn shelf_life_hours(kind: ItemKind) -> Option { match kind { ItemKind::Meat | ItemKind::Carcass => Some(48.0), ItemKind::Berries => Some(72.0), ItemKind::Meal => Some(96.0), ItemKind::Vegetables => Some(120.0), _ => None, } } pub fn spoilage(kind: ItemKind, roofed: bool, ticks: f32) -> f32 { let Some(hours) = shelf_life_hours(kind) else { return 0.0; }; let rate = ticks / (hours * TICKS_PER_HOUR as f32); if roofed { rate * ROOFED_SPOIL_FACTOR } else { rate } } const FOOD_SEARCH_TILES: usize = 15000; #[derive(Component, Debug, Clone, Copy, PartialEq)] pub struct Hunger(pub f32); impl Default for Hunger { fn default() -> Self { Self(1.0) } } pub fn wants_food(fed: f32) -> bool { fed < HUNGRY } pub fn portion(kind: ItemKind, fed: f32) -> u32 { ((1.0 - fed) / kind.nutrition()).ceil().max(1.0) as u32 } pub fn find_food( map: &WorldMap, reservations: &Reservations, reach: &ColonyReach, items: &[(Item, TilePos)], from: UVec2, ) -> Option { let tiles = |accept: fn(ItemKind) -> bool| -> HashSet { items .iter() .filter(|(item, pos)| accept(item.kind) && reservations.is_free(Target::Pickup(pos.0))) .map(|(_, pos)| pos.0) .collect() }; let search = |tiles: HashSet| reach.nearest(map, from, FOOD_SEARCH_TILES, &tiles); search(tiles(|k| k == ItemKind::Meal)).or_else(|| search(tiles(ItemKind::is_raw_food))) } pub fn spoil_food( mut commands: Commands, tick: Res, map: Res, mut items: Query<(Entity, &mut Item, &TilePos)>, mut carried: Query<&mut Carrying>, ) { if !tick.0.is_multiple_of(SPOIL_INTERVAL_TICKS) { return; } let ticks = SPOIL_INTERVAL_TICKS as f32 * spoil_factor(outdoor_temperature(*tick)); for (entity, mut item, pos) in &mut items { item.spoil += spoilage(item.kind, map.is_roofed(pos.0.as_ivec2()), ticks); if item.spoil >= 1.0 { commands.entity(entity).despawn(); } } for mut carrying in &mut carried { if let Some(load) = &mut carrying.0 { load.spoil += spoilage(load.kind, false, ticks); if load.spoil >= 1.0 { carrying.0 = None; } } } } pub fn update_hunger( mut colonists: Query<(&mut Hunger, &CurrentJob, &TilePos, &Path, &Warmth), With>, ) { for (mut hunger, job, tile, path, warmth) in &mut colonists { let rate = if is_sleeping(job, tile, path) { HUNGER_PER_TICK * SLEEPING_HUNGER_FACTOR } else { HUNGER_PER_TICK }; let rate = if warmth.cold() { rate * COLD_HUNGER_FACTOR } else { rate }; hunger.0 = (hunger.0 - rate).max(0.0); } } #[cfg(test)] mod tests { use super::*; use crate::world::Terrain; #[test] fn food_spoils_slower_under_a_roof_and_materials_never() { let day = 24.0 * TICKS_PER_HOUR as f32; assert_eq!(spoilage(ItemKind::Meat, false, 2.0 * day), 1.0); assert_eq!(spoilage(ItemKind::Meat, true, 2.0 * day), 0.5); assert!( spoilage(ItemKind::Berries, false, day) > spoilage(ItemKind::Vegetables, false, day) ); assert_eq!(spoilage(ItemKind::Wood, false, 100.0 * day), 0.0); } #[test] fn rotten_food_disappears() { let mut map = WorldMap::filled(UVec2::splat(4), Terrain::Grass); map.set_roof(UVec2::ONE, true); let mut app = App::new(); app.insert_resource(map) .init_resource::() .add_systems(Update, spoil_food); let nearly_rotten = Item { spoil: 0.9999, ..Item::new(ItemKind::Meat, 5) }; let outside = app .world_mut() .spawn((nearly_rotten, TilePos(UVec2::ZERO))) .id(); let fresh = app .world_mut() .spawn((Item::new(ItemKind::Meat, 5), TilePos(UVec2::ONE))) .id(); let wood = app .world_mut() .spawn(( Item { spoil: 0.9999, ..Item::new(ItemKind::Wood, 5) }, TilePos(UVec2::ZERO), )) .id(); app.update(); assert!(app.world().get_entity(outside).is_err()); assert!(app.world().get_entity(fresh).is_ok()); assert!(app.world().get_entity(wood).is_ok()); } #[test] fn merged_stacks_average_their_freshness() { let mut stack = Item { spoil: 0.5, ..Item::new(ItemKind::Berries, 10) }; stack.merge(Item::new(ItemKind::Berries, 10)); assert_eq!(stack.count, 20); assert_eq!(stack.spoil, 0.25); } #[test] fn portions_fill_a_colonist_up() { assert_eq!(portion(ItemKind::Meal, 0.2), 1); assert_eq!(portion(ItemKind::Berries, 0.3), 7); assert_eq!(portion(ItemKind::Berries, 0.99), 1); } #[test] fn colonists_prefer_meals_over_raw_food() { let map = WorldMap::filled(UVec2::splat(20), Terrain::Grass); let reservations = Reservations::default(); let food = |kind, x| (Item::new(kind, 5), TilePos(UVec2::new(x, 0))); let items = [ food(ItemKind::Berries, 1), food(ItemKind::Meal, 15), food(ItemKind::Wood, 0), ]; assert_eq!( find_food( &map, &reservations, &ColonyReach::default(), &items, UVec2::ZERO ), Some(UVec2::new(15, 0)) ); assert_eq!( find_food( &map, &reservations, &ColonyReach::default(), &items[..1], UVec2::ZERO ), Some(UVec2::new(1, 0)) ); assert_eq!( find_food( &map, &reservations, &ColonyReach::default(), &items[2..], UVec2::ZERO ), None ); } }