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