src/mood.rs 11.2 K · 408 lines · raw · history

1 use bevy::prelude::*;
2 use serde::{Deserialize, Serialize};
3
4 use crate::{
5 climate::Warmth,
6 colonist::Colonist,
7 family::{Child, Family, Homes, Partner},
8 food::{HUNGRY, Hunger},
9 items::{Carrying, ItemAssets, ItemKind, ItemQuery, drop_items},
10 jobs::Reservations,
11 needs::Rest,
12 population::ColonyLog,
13 rng::SimRng,
14 sim::{SimSystems, SimTick, TICKS_PER_HOUR},
15 structures::{Structure, StructureKind},
16 world::{TilePos, WorldMap},
17 worldgen::WorldGenerated,
18 };
19
20 pub struct MoodPlugin;
21
22 impl Plugin for MoodPlugin {
23 fn build(&self, app: &mut App) {
24 app.add_message::<Ate>()
25 .init_resource::<Losses>()
26 .add_systems(Update, clear_losses.run_if(on_message::<WorldGenerated>))
27 .add_systems(
28 FixedUpdate,
29 (assign_age, digest, update_mood, leave, old_age)
30 .chain()
31 .in_set(SimSystems::Act),
32 );
33 }
34 }
35
36 pub const DAY_TICKS: u64 = 24 * TICKS_PER_HOUR;
37 pub const SICK_TICKS: u64 = 12 * TICKS_PER_HOUR;
38 pub const MOOD_HOURS: f32 = 12.0;
39 pub const LEAVE_MOOD: f32 = 0.2;
40 pub const LEAVE_AFTER_HOURS: u32 = 48;
41 pub const OLD_AGE: u64 = 60;
42 pub const OLD_AGE_DEATH_PER_YEAR: f32 = 0.03;
43 const MEAL_MEMORY_TICKS: u64 = DAY_TICKS;
44 const GRIEF_TICKS: u64 = 2 * DAY_TICKS;
45 const ADULT_AGES: std::ops::Range<i32> = 18..41;
46
47 #[derive(Message, Debug, Clone, Copy)]
48 pub struct Ate {
49 pub colonist: Entity,
50 pub kind: ItemKind,
51 }
52
53 #[derive(Component, Debug, Clone, Copy, PartialEq)]
54 pub struct Mood(pub f32);
55
56 impl Default for Mood {
57 fn default() -> Self {
58 Self(0.6)
59 }
60 }
61
62 #[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
63 pub struct Sick {
64 pub until: u64,
65 }
66
67 #[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
68 pub struct LastMeal {
69 pub cooked: bool,
70 pub tick: u64,
71 }
72
73 #[derive(Component, Debug, Default, Clone, Copy, PartialEq, Eq)]
74 pub struct Unhappy(pub u32);
75
76 #[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
77 pub struct Age {
78 pub born: i64,
79 }
80
81 impl Age {
82 pub fn years(&self, tick: SimTick) -> u64 {
83 ((tick.0 as i64 - self.born).max(0) as u64) / DAY_TICKS
84 }
85 }
86
87 #[derive(Resource, Default, Debug, Serialize, Deserialize)]
88 pub struct Losses(pub Vec<u64>);
89
90 fn clear_losses(mut losses: ResMut<Losses>) {
91 losses.0.clear();
92 }
93
94 pub fn poison_chance(kind: ItemKind) -> f32 {
95 match kind {
96 ItemKind::Meat => 0.15,
97 ItemKind::Berries | ItemKind::Vegetables => 0.04,
98 _ => 0.0,
99 }
100 }
101
102 #[derive(Debug, Default, Clone, Copy)]
103 pub struct MoodFactors {
104 pub fed: f32,
105 pub rest: f32,
106 pub sick: bool,
107 pub last_meal_cooked: Option<bool>,
108 pub partner: bool,
109 pub home: bool,
110 pub grieving: bool,
111 pub cold: bool,
112 pub beauty: bool,
113 }
114
115 pub fn mood_target(f: MoodFactors) -> f32 {
116 let mut mood: f32 = 0.5;
117 mood += if f.fed < HUNGRY { -0.25 } else { 0.1 };
118 mood += if f.rest < 0.2 { -0.15 } else { 0.05 };
119 mood += match f.last_meal_cooked {
120 Some(true) => 0.1,
121 Some(false) => -0.05,
122 None => 0.0,
123 };
124 if f.sick {
125 mood -= 0.2;
126 }
127 if f.partner {
128 mood += 0.1;
129 }
130 if f.home {
131 mood += 0.1;
132 }
133 if f.grieving {
134 mood -= 0.15;
135 }
136 if f.cold {
137 mood -= 0.15;
138 }
139 if f.beauty {
140 mood += 0.1;
141 }
142 mood.clamp(0.0, 1.0)
143 }
144
145 fn assign_age(
146 mut commands: Commands,
147 tick: Res<SimTick>,
148 mut rng: ResMut<SimRng>,
149 newcomers: Query<(Entity, Has<Child>), (With<Colonist>, Without<Age>)>,
150 ) {
151 for (entity, child) in &newcomers {
152 let years = if child { 0 } else { rng.range_i32(ADULT_AGES) };
153 let born = tick.0 as i64 - years as i64 * DAY_TICKS as i64;
154 commands
155 .entity(entity)
156 .insert((Age { born }, Mood::default()));
157 }
158 }
159
160 fn digest(
161 mut commands: Commands,
162 tick: Res<SimTick>,
163 mut rng: ResMut<SimRng>,
164 mut log: ResMut<ColonyLog>,
165 mut ate: MessageReader<Ate>,
166 colonists: Query<(&Colonist, Option<&Sick>)>,
167 ) {
168 for meal in ate.read() {
169 let Ok((colonist, sick)) = colonists.get(meal.colonist) else {
170 continue;
171 };
172 let mut entity = commands.entity(meal.colonist);
173 entity.insert(LastMeal {
174 cooked: !meal.kind.is_raw_food(),
175 tick: tick.0,
176 });
177 if sick.is_none() && rng.range_f32(0.0..1.0) < poison_chance(meal.kind) {
178 entity.insert(Sick {
179 until: tick.0 + SICK_TICKS,
180 });
181 log.push(*tick, format!("{} has food poisoning", colonist.name));
182 }
183 }
184 }
185
186 #[allow(clippy::type_complexity)]
187 fn update_mood(
188 mut commands: Commands,
189 tick: Res<SimTick>,
190 homes: Res<Homes>,
191 map: Res<WorldMap>,
192 losses: Res<Losses>,
193 structures: Query<(&TilePos, &Structure), Without<Colonist>>,
194 mut colonists: Query<(
195 Entity,
196 &mut Mood,
197 &Hunger,
198 &Rest,
199 Option<&Sick>,
200 Option<&LastMeal>,
201 Has<Partner>,
202 Option<&Family>,
203 &Warmth,
204 )>,
205 ) {
206 if !tick.0.is_multiple_of(TICKS_PER_HOUR) {
207 return;
208 }
209 let grieving = losses.0.iter().any(|&t| tick.0 < t + GRIEF_TICKS);
210 let statues: Vec<UVec2> = structures
211 .iter()
212 .filter(|(_, s)| s.kind == StructureKind::Statue)
213 .map(|(pos, _)| pos.0)
214 .collect();
215 for (entity, mut mood, hunger, rest, sick, meal, partner, family, warmth) in &mut colonists {
216 let sick_now = sick.is_some_and(|s| tick.0 < s.until);
217 if sick.is_some() && !sick_now {
218 commands.entity(entity).remove::<Sick>();
219 }
220 let own_home = homes.of(family.copied());
221 let home = own_home.is_some_and(|h| h.interior().all(|t| map.is_roofed(t.as_ivec2())));
222 let beauty = own_home.is_some_and(|h| statues.iter().any(|&t| h.contains(t)));
223 let target = mood_target(MoodFactors {
224 fed: hunger.0,
225 rest: rest.0,
226 sick: sick_now,
227 last_meal_cooked: meal
228 .filter(|m| tick.0 < m.tick + MEAL_MEMORY_TICKS)
229 .map(|m| m.cooked),
230 partner,
231 home,
232 grieving,
233 cold: warmth.cold(),
234 beauty,
235 });
236 mood.0 += (target - mood.0) / MOOD_HOURS;
237 }
238 }
239
240 #[allow(clippy::too_many_arguments)]
241 fn leave(
242 mut commands: Commands,
243 tick: Res<SimTick>,
244 item_assets: Res<ItemAssets>,
245 mut log: ResMut<ColonyLog>,
246 mut reservations: ResMut<Reservations>,
247 mut items: ItemQuery,
248 mut colonists: Query<
249 (
250 Entity,
251 &Colonist,
252 &Mood,
253 &TilePos,
254 &Carrying,
255 Option<&mut Unhappy>,
256 ),
257 Without<Child>,
258 >,
259 ) {
260 if !tick.0.is_multiple_of(TICKS_PER_HOUR) {
261 return;
262 }
263 for (entity, colonist, mood, tile, carrying, unhappy) in &mut colonists {
264 let hours = if mood.0 < LEAVE_MOOD {
265 unhappy.as_ref().map_or(0, |u| u.0) + 1
266 } else {
267 0
268 };
269 match unhappy {
270 Some(mut u) => u.0 = hours,
271 None => {
272 commands.entity(entity).insert(Unhappy(hours));
273 }
274 }
275 if hours < LEAVE_AFTER_HOURS {
276 continue;
277 }
278 if let Some(load) = carrying.0 {
279 drop_items(&mut commands, &mut items, &item_assets, tile.0, load);
280 }
281 reservations.release_all(entity);
282 commands.entity(entity).despawn();
283 log.push(*tick, format!("{} left the colony, unhappy", colonist.name));
284 }
285 }
286
287 pub fn dies_of_old_age(years: u64, roll: f32) -> bool {
288 years >= OLD_AGE && roll < (years - OLD_AGE + 1) as f32 * OLD_AGE_DEATH_PER_YEAR
289 }
290
291 #[allow(clippy::too_many_arguments)]
292 fn old_age(
293 mut commands: Commands,
294 tick: Res<SimTick>,
295 item_assets: Res<ItemAssets>,
296 mut rng: ResMut<SimRng>,
297 mut log: ResMut<ColonyLog>,
298 mut losses: ResMut<Losses>,
299 mut reservations: ResMut<Reservations>,
300 mut items: ItemQuery,
301 colonists: Query<(Entity, &Colonist, &Age, &TilePos, &Carrying)>,
302 ) {
303 if !tick.0.is_multiple_of(DAY_TICKS) {
304 return;
305 }
306 for (entity, colonist, age, tile, carrying) in &colonists {
307 if !dies_of_old_age(age.years(*tick), rng.range_f32(0.0..1.0)) {
308 continue;
309 }
310 if let Some(load) = carrying.0 {
311 drop_items(&mut commands, &mut items, &item_assets, tile.0, load);
312 }
313 reservations.release_all(entity);
314 commands.entity(entity).despawn();
315 losses.0.push(tick.0);
316 log.push(*tick, format!("{} died of old age", colonist.name));
317 }
318 }
319
320 #[cfg(test)]
321 mod tests {
322 use super::*;
323
324 fn content() -> MoodFactors {
325 MoodFactors {
326 fed: 0.8,
327 rest: 0.8,
328 ..default()
329 }
330 }
331
332 #[test]
333 fn needs_meals_family_and_loss_move_mood() {
334 let base = mood_target(content());
335 let hungry = mood_target(MoodFactors {
336 fed: 0.1,
337 ..content()
338 });
339 assert!(hungry < base);
340 let settled = mood_target(MoodFactors {
341 partner: true,
342 home: true,
343 beauty: true,
344 last_meal_cooked: Some(true),
345 ..content()
346 });
347 assert!(settled > base + 0.25);
348 let miserable = mood_target(MoodFactors {
349 fed: 0.1,
350 rest: 0.1,
351 sick: true,
352 grieving: true,
353 cold: true,
354 last_meal_cooked: Some(false),
355 ..default()
356 });
357 assert!(miserable < LEAVE_MOOD);
358 }
359
360 // Cooking is what makes raw meat safe: meals never poison, raw meat is the riskiest.
361 #[test]
362 fn raw_food_can_poison_and_meals_never() {
363 assert_eq!(poison_chance(ItemKind::Meal), 0.0);
364 assert!(poison_chance(ItemKind::Meat) > poison_chance(ItemKind::Berries));
365 }
366
367 #[test]
368 fn old_age_comes_for_everyone_eventually() {
369 assert!(!dies_of_old_age(OLD_AGE - 1, 0.0));
370 assert!(dies_of_old_age(OLD_AGE, 0.0));
371 assert!(!dies_of_old_age(OLD_AGE, 0.5));
372 assert!(dies_of_old_age(OLD_AGE + 40, 0.99));
373 }
374
375 #[test]
376 fn colonists_leave_after_two_unhappy_days() {
377 let mut app = App::new();
378 app.init_resource::<SimTick>()
379 .init_resource::<ItemAssets>()
380 .init_resource::<ColonyLog>()
381 .init_resource::<Reservations>()
382 .add_systems(Update, leave);
383 let spawn = |app: &mut App, mood: f32| {
384 app.world_mut()
385 .spawn((
386 Colonist { name: "Ada".into() },
387 Mood(mood),
388 Unhappy(LEAVE_AFTER_HOURS - 1),
389 ))
390 .id()
391 };
392 let gloomy = spawn(&mut app, LEAVE_MOOD / 2.0);
393 let cheered_up = spawn(&mut app, 0.5);
394 app.update();
395 assert!(app.world().get_entity(gloomy).is_err());
396 assert_eq!(app.world().get::<Unhappy>(cheered_up), Some(&Unhappy(0)));
397 let log = app.world().resource::<ColonyLog>();
398 assert!(log.0[0].ends_with("Ada left the colony, unhappy"));
399 }
400
401 #[test]
402 fn ages_count_days_as_years() {
403 let age = Age {
404 born: -(20 * DAY_TICKS as i64),
405 };
406 assert_eq!(age.years(SimTick(DAY_TICKS / 2)), 20);
407 }
408 }