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