use bevy::prelude::*; use serde::{Deserialize, Serialize}; use crate::{ climate::Warmth, colonist::Colonist, family::{Child, Family, Homes, Partner}, food::{HUNGRY, Hunger}, items::{Carrying, ItemAssets, ItemKind, ItemQuery, drop_items}, jobs::Reservations, needs::Rest, population::ColonyLog, rng::SimRng, rooms::Rooms, sim::{SimSystems, SimTick, TICKS_PER_HOUR}, skills::Skills, threats::{HURT, Health}, world::{TilePos, WorldMap}, worldgen::WorldGenerated, }; pub struct MoodPlugin; impl Plugin for MoodPlugin { fn build(&self, app: &mut App) { app.add_message::() .init_resource::() .add_systems(Update, clear_losses.run_if(on_message::)) .add_systems( FixedUpdate, (assign_age, digest, update_mood, leave, old_age) .chain() .in_set(SimSystems::Act), ); } } pub const DAY_TICKS: u64 = 24 * TICKS_PER_HOUR; pub const SICK_TICKS: u64 = 12 * TICKS_PER_HOUR; pub const MOOD_HOURS: f32 = 12.0; pub const LEAVE_MOOD: f32 = 0.2; pub const ROOM_MOOD: f32 = 0.3; pub const LEAVE_AFTER_HOURS: u32 = 48; pub const OLD_AGE: u64 = 60; pub const OLD_AGE_DEATH_PER_YEAR: f32 = 0.03; const MEAL_MEMORY_TICKS: u64 = DAY_TICKS; const GRIEF_TICKS: u64 = 2 * DAY_TICKS; const ADULT_AGES: std::ops::Range = 18..41; #[derive(Message, Debug, Clone, Copy)] pub struct Ate { pub colonist: Entity, pub kind: ItemKind, } #[derive(Component, Debug, Clone, Copy, PartialEq)] pub struct Mood(pub f32); impl Default for Mood { fn default() -> Self { Self(0.6) } } #[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] pub struct Sick { pub until: u64, } #[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] pub struct LastMeal { pub cooked: bool, pub tick: u64, } #[derive(Component, Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct Unhappy(pub u32); #[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] pub struct Age { pub born: i64, } impl Age { pub fn years(&self, tick: SimTick) -> u64 { ((tick.0 as i64 - self.born).max(0) as u64) / DAY_TICKS } } #[derive(Resource, Default, Debug, Serialize, Deserialize)] pub struct Losses(pub Vec); fn clear_losses(mut losses: ResMut) { losses.0.clear(); } pub fn poison_chance(kind: ItemKind) -> f32 { match kind { ItemKind::Meat => 0.15, ItemKind::Berries | ItemKind::Vegetables => 0.04, _ => 0.0, } } #[derive(Debug, Default, Clone, Copy)] pub struct MoodFactors { pub fed: f32, pub rest: f32, pub sick: bool, pub last_meal_cooked: Option, pub partner: bool, pub home: bool, pub grieving: bool, pub cold: bool, pub room: f32, pub hurt: bool, } pub fn mood_target(f: MoodFactors) -> f32 { let mut mood: f32 = 0.5; mood += if f.fed < HUNGRY { -0.25 } else { 0.1 }; mood += if f.rest < 0.2 { -0.15 } else { 0.05 }; mood += match f.last_meal_cooked { Some(true) => 0.1, Some(false) => -0.05, None => 0.0, }; if f.sick { mood -= 0.2; } if f.partner { mood += 0.1; } if f.home { mood += 0.1; } if f.grieving { mood -= 0.15; } if f.cold { mood -= 0.15; } mood += ROOM_MOOD * f.room; if f.hurt { mood -= 0.1; } mood.clamp(0.0, 1.0) } fn assign_age( mut commands: Commands, tick: Res, mut rng: ResMut, newcomers: Query<(Entity, Has), (With, Without)>, ) { for (entity, child) in &newcomers { let years = if child { 0 } else { rng.range_i32(ADULT_AGES) }; let born = tick.0 as i64 - years as i64 * DAY_TICKS as i64; let skills = if child { Skills::uniform(0) } else { Skills::random(&mut rng) }; commands .entity(entity) .insert((Age { born }, Mood::default(), skills)); } } fn digest( mut commands: Commands, tick: Res, mut rng: ResMut, mut log: ResMut, mut ate: MessageReader, colonists: Query<(&Colonist, Option<&Sick>)>, ) { for meal in ate.read() { let Ok((colonist, sick)) = colonists.get(meal.colonist) else { continue; }; let mut entity = commands.entity(meal.colonist); entity.insert(LastMeal { cooked: !meal.kind.is_raw_food(), tick: tick.0, }); if sick.is_none() && rng.range_f32(0.0..1.0) < poison_chance(meal.kind) { entity.insert(Sick { until: tick.0 + SICK_TICKS, }); log.push(*tick, format!("{} has food poisoning", colonist.name)); } } } #[allow(clippy::type_complexity)] fn update_mood( mut commands: Commands, tick: Res, homes: Res, map: Res, losses: Res, rooms: Res, mut colonists: Query<( Entity, &mut Mood, &Hunger, &Rest, Option<&Sick>, Option<&LastMeal>, Has, Option<&Family>, &Warmth, &Health, )>, ) { if !tick.0.is_multiple_of(TICKS_PER_HOUR) { return; } let grieving = losses.0.iter().any(|&t| tick.0 < t + GRIEF_TICKS); for (entity, mut mood, hunger, rest, sick, meal, partner, family, warmth, health) in &mut colonists { let sick_now = sick.is_some_and(|s| tick.0 < s.until); if sick.is_some() && !sick_now { commands.entity(entity).remove::(); } let own_home = homes.of(family.copied()); let home = own_home.is_some_and(|h| h.interior().all(|t| map.is_roofed(t.as_ivec2()))); let room = own_home .and_then(|h| h.interior().next()) .and_then(|t| rooms.at(t)) .map_or(0.0, |room| room.quality); let target = mood_target(MoodFactors { fed: hunger.0, rest: rest.0, sick: sick_now, last_meal_cooked: meal .filter(|m| tick.0 < m.tick + MEAL_MEMORY_TICKS) .map(|m| m.cooked), partner, home, grieving, cold: warmth.cold(), room, hurt: health.0 < HURT, }); mood.0 += (target - mood.0) / MOOD_HOURS; } } #[allow(clippy::too_many_arguments)] fn leave( mut commands: Commands, tick: Res, item_assets: Res, mut log: ResMut, mut reservations: ResMut, mut items: ItemQuery, mut colonists: Query< ( Entity, &Colonist, &Mood, &TilePos, &Carrying, Option<&mut Unhappy>, ), Without, >, ) { if !tick.0.is_multiple_of(TICKS_PER_HOUR) { return; } for (entity, colonist, mood, tile, carrying, unhappy) in &mut colonists { let hours = if mood.0 < LEAVE_MOOD { unhappy.as_ref().map_or(0, |u| u.0) + 1 } else { 0 }; match unhappy { Some(mut u) => u.0 = hours, None => { commands.entity(entity).insert(Unhappy(hours)); } } if hours < LEAVE_AFTER_HOURS { continue; } if let Some(load) = carrying.0 { drop_items(&mut commands, &mut items, &item_assets, tile.0, load); } reservations.release_all(entity); commands.entity(entity).despawn(); log.push(*tick, format!("{} left the colony, unhappy", colonist.name)); } } pub fn dies_of_old_age(years: u64, roll: f32) -> bool { years >= OLD_AGE && roll < (years - OLD_AGE + 1) as f32 * OLD_AGE_DEATH_PER_YEAR } #[allow(clippy::too_many_arguments)] fn old_age( mut commands: Commands, tick: Res, item_assets: Res, mut rng: ResMut, mut log: ResMut, mut losses: ResMut, mut reservations: ResMut, mut items: ItemQuery, colonists: Query<(Entity, &Colonist, &Age, &TilePos, &Carrying)>, ) { if !tick.0.is_multiple_of(DAY_TICKS) { return; } for (entity, colonist, age, tile, carrying) in &colonists { if !dies_of_old_age(age.years(*tick), rng.range_f32(0.0..1.0)) { continue; } if let Some(load) = carrying.0 { drop_items(&mut commands, &mut items, &item_assets, tile.0, load); } reservations.release_all(entity); commands.entity(entity).despawn(); losses.0.push(tick.0); log.push(*tick, format!("{} died of old age", colonist.name)); } } #[cfg(test)] mod tests { use super::*; fn content() -> MoodFactors { MoodFactors { fed: 0.8, rest: 0.8, ..default() } } #[test] fn needs_meals_family_and_loss_move_mood() { let base = mood_target(content()); let hungry = mood_target(MoodFactors { fed: 0.1, ..content() }); assert!(hungry < base); let settled = mood_target(MoodFactors { partner: true, home: true, room: 1.0, last_meal_cooked: Some(true), ..content() }); assert!(settled > base + 0.25); let miserable = mood_target(MoodFactors { fed: 0.1, rest: 0.1, sick: true, grieving: true, cold: true, last_meal_cooked: Some(false), ..default() }); assert!(miserable < LEAVE_MOOD); } // Cooking is what makes raw meat safe: meals never poison, raw meat is the riskiest. #[test] fn raw_food_can_poison_and_meals_never() { assert_eq!(poison_chance(ItemKind::Meal), 0.0); assert!(poison_chance(ItemKind::Meat) > poison_chance(ItemKind::Berries)); } #[test] fn old_age_comes_for_everyone_eventually() { assert!(!dies_of_old_age(OLD_AGE - 1, 0.0)); assert!(dies_of_old_age(OLD_AGE, 0.0)); assert!(!dies_of_old_age(OLD_AGE, 0.5)); assert!(dies_of_old_age(OLD_AGE + 40, 0.99)); } #[test] fn colonists_leave_after_two_unhappy_days() { let mut app = App::new(); app.init_resource::() .init_resource::() .init_resource::() .init_resource::() .add_systems(Update, leave); let spawn = |app: &mut App, mood: f32| { app.world_mut() .spawn(( Colonist { name: "Ada".into() }, Mood(mood), Unhappy(LEAVE_AFTER_HOURS - 1), )) .id() }; let gloomy = spawn(&mut app, LEAVE_MOOD / 2.0); let cheered_up = spawn(&mut app, 0.5); app.update(); assert!(app.world().get_entity(gloomy).is_err()); assert_eq!(app.world().get::(cheered_up), Some(&Unhappy(0))); let log = app.world().resource::(); assert!(log.0[0].ends_with("Ada left the colony, unhappy")); } #[test] fn ages_count_days_as_years() { let age = Age { born: -(20 * DAY_TICKS as i64), }; assert_eq!(age.years(SimTick(DAY_TICKS / 2)), 20); } }