src/mood.rs 11.4 K · 415 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, floored},
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 pub floored: bool,
114 }
115
116 pub fn mood_target(f: MoodFactors) -> f32 {
117 let mut mood: f32 = 0.5;
118 mood += if f.fed < HUNGRY { -0.25 } else { 0.1 };
119 mood += if f.rest < 0.2 { -0.15 } else { 0.05 };
120 mood += match f.last_meal_cooked {
121 Some(true) => 0.1,
122 Some(false) => -0.05,
123 None => 0.0,
124 };
125 if f.sick {
126 mood -= 0.2;
127 }
128 if f.partner {
129 mood += 0.1;
130 }
131 if f.home {
132 mood += 0.1;
133 }
134 if f.grieving {
135 mood -= 0.15;
136 }
137 if f.cold {
138 mood -= 0.15;
139 }
140 if f.beauty {
141 mood += 0.1;
142 }
143 if f.floored {
144 mood += 0.05;
145 }
146 mood.clamp(0.0, 1.0)
147 }
148
149 fn assign_age(
150 mut commands: Commands,
151 tick: Res<SimTick>,
152 mut rng: ResMut<SimRng>,
153 newcomers: Query<(Entity, Has<Child>), (With<Colonist>, Without<Age>)>,
154 ) {
155 for (entity, child) in &newcomers {
156 let years = if child { 0 } else { rng.range_i32(ADULT_AGES) };
157 let born = tick.0 as i64 - years as i64 * DAY_TICKS as i64;
158 commands
159 .entity(entity)
160 .insert((Age { born }, Mood::default()));
161 }
162 }
163
164 fn digest(
165 mut commands: Commands,
166 tick: Res<SimTick>,
167 mut rng: ResMut<SimRng>,
168 mut log: ResMut<ColonyLog>,
169 mut ate: MessageReader<Ate>,
170 colonists: Query<(&Colonist, Option<&Sick>)>,
171 ) {
172 for meal in ate.read() {
173 let Ok((colonist, sick)) = colonists.get(meal.colonist) else {
174 continue;
175 };
176 let mut entity = commands.entity(meal.colonist);
177 entity.insert(LastMeal {
178 cooked: !meal.kind.is_raw_food(),
179 tick: tick.0,
180 });
181 if sick.is_none() && rng.range_f32(0.0..1.0) < poison_chance(meal.kind) {
182 entity.insert(Sick {
183 until: tick.0 + SICK_TICKS,
184 });
185 log.push(*tick, format!("{} has food poisoning", colonist.name));
186 }
187 }
188 }
189
190 #[allow(clippy::type_complexity)]
191 fn update_mood(
192 mut commands: Commands,
193 tick: Res<SimTick>,
194 homes: Res<Homes>,
195 map: Res<WorldMap>,
196 losses: Res<Losses>,
197 structures: Query<(&TilePos, &Structure), Without<Colonist>>,
198 mut colonists: Query<(
199 Entity,
200 &mut Mood,
201 &Hunger,
202 &Rest,
203 Option<&Sick>,
204 Option<&LastMeal>,
205 Has<Partner>,
206 Option<&Family>,
207 &Warmth,
208 )>,
209 ) {
210 if !tick.0.is_multiple_of(TICKS_PER_HOUR) {
211 return;
212 }
213 let grieving = losses.0.iter().any(|&t| tick.0 < t + GRIEF_TICKS);
214 let statues: Vec<UVec2> = structures
215 .iter()
216 .filter(|(_, s)| s.kind == StructureKind::Statue)
217 .map(|(pos, _)| pos.0)
218 .collect();
219 for (entity, mut mood, hunger, rest, sick, meal, partner, family, warmth) in &mut colonists {
220 let sick_now = sick.is_some_and(|s| tick.0 < s.until);
221 if sick.is_some() && !sick_now {
222 commands.entity(entity).remove::<Sick>();
223 }
224 let own_home = homes.of(family.copied());
225 let home = own_home.is_some_and(|h| h.interior().all(|t| map.is_roofed(t.as_ivec2())));
226 let beauty = own_home.is_some_and(|h| statues.iter().any(|&t| h.contains(t)));
227 let floored = own_home.is_some_and(|h| h.interior().all(|t| floored(&map, t)));
228 let target = mood_target(MoodFactors {
229 fed: hunger.0,
230 rest: rest.0,
231 sick: sick_now,
232 last_meal_cooked: meal
233 .filter(|m| tick.0 < m.tick + MEAL_MEMORY_TICKS)
234 .map(|m| m.cooked),
235 partner,
236 home,
237 grieving,
238 cold: warmth.cold(),
239 beauty,
240 floored,
241 });
242 mood.0 += (target - mood.0) / MOOD_HOURS;
243 }
244 }
245
246 #[allow(clippy::too_many_arguments)]
247 fn leave(
248 mut commands: Commands,
249 tick: Res<SimTick>,
250 item_assets: Res<ItemAssets>,
251 mut log: ResMut<ColonyLog>,
252 mut reservations: ResMut<Reservations>,
253 mut items: ItemQuery,
254 mut colonists: Query<
255 (
256 Entity,
257 &Colonist,
258 &Mood,
259 &TilePos,
260 &Carrying,
261 Option<&mut Unhappy>,
262 ),
263 Without<Child>,
264 >,
265 ) {
266 if !tick.0.is_multiple_of(TICKS_PER_HOUR) {
267 return;
268 }
269 for (entity, colonist, mood, tile, carrying, unhappy) in &mut colonists {
270 let hours = if mood.0 < LEAVE_MOOD {
271 unhappy.as_ref().map_or(0, |u| u.0) + 1
272 } else {
273 0
274 };
275 match unhappy {
276 Some(mut u) => u.0 = hours,
277 None => {
278 commands.entity(entity).insert(Unhappy(hours));
279 }
280 }
281 if hours < LEAVE_AFTER_HOURS {
282 continue;
283 }
284 if let Some(load) = carrying.0 {
285 drop_items(&mut commands, &mut items, &item_assets, tile.0, load);
286 }
287 reservations.release_all(entity);
288 commands.entity(entity).despawn();
289 log.push(*tick, format!("{} left the colony, unhappy", colonist.name));
290 }
291 }
292
293 pub fn dies_of_old_age(years: u64, roll: f32) -> bool {
294 years >= OLD_AGE && roll < (years - OLD_AGE + 1) as f32 * OLD_AGE_DEATH_PER_YEAR
295 }
296
297 #[allow(clippy::too_many_arguments)]
298 fn old_age(
299 mut commands: Commands,
300 tick: Res<SimTick>,
301 item_assets: Res<ItemAssets>,
302 mut rng: ResMut<SimRng>,
303 mut log: ResMut<ColonyLog>,
304 mut losses: ResMut<Losses>,
305 mut reservations: ResMut<Reservations>,
306 mut items: ItemQuery,
307 colonists: Query<(Entity, &Colonist, &Age, &TilePos, &Carrying)>,
308 ) {
309 if !tick.0.is_multiple_of(DAY_TICKS) {
310 return;
311 }
312 for (entity, colonist, age, tile, carrying) in &colonists {
313 if !dies_of_old_age(age.years(*tick), rng.range_f32(0.0..1.0)) {
314 continue;
315 }
316 if let Some(load) = carrying.0 {
317 drop_items(&mut commands, &mut items, &item_assets, tile.0, load);
318 }
319 reservations.release_all(entity);
320 commands.entity(entity).despawn();
321 losses.0.push(tick.0);
322 log.push(*tick, format!("{} died of old age", colonist.name));
323 }
324 }
325
326 #[cfg(test)]
327 mod tests {
328 use super::*;
329
330 fn content() -> MoodFactors {
331 MoodFactors {
332 fed: 0.8,
333 rest: 0.8,
334 ..default()
335 }
336 }
337
338 #[test]
339 fn needs_meals_family_and_loss_move_mood() {
340 let base = mood_target(content());
341 let hungry = mood_target(MoodFactors {
342 fed: 0.1,
343 ..content()
344 });
345 assert!(hungry < base);
346 let settled = mood_target(MoodFactors {
347 partner: true,
348 home: true,
349 beauty: true,
350 floored: true,
351 last_meal_cooked: Some(true),
352 ..content()
353 });
354 assert!(settled > base + 0.25);
355 let miserable = mood_target(MoodFactors {
356 fed: 0.1,
357 rest: 0.1,
358 sick: true,
359 grieving: true,
360 cold: true,
361 last_meal_cooked: Some(false),
362 ..default()
363 });
364 assert!(miserable < LEAVE_MOOD);
365 }
366
367 // Cooking is what makes raw meat safe: meals never poison, raw meat is the riskiest.
368 #[test]
369 fn raw_food_can_poison_and_meals_never() {
370 assert_eq!(poison_chance(ItemKind::Meal), 0.0);
371 assert!(poison_chance(ItemKind::Meat) > poison_chance(ItemKind::Berries));
372 }
373
374 #[test]
375 fn old_age_comes_for_everyone_eventually() {
376 assert!(!dies_of_old_age(OLD_AGE - 1, 0.0));
377 assert!(dies_of_old_age(OLD_AGE, 0.0));
378 assert!(!dies_of_old_age(OLD_AGE, 0.5));
379 assert!(dies_of_old_age(OLD_AGE + 40, 0.99));
380 }
381
382 #[test]
383 fn colonists_leave_after_two_unhappy_days() {
384 let mut app = App::new();
385 app.init_resource::<SimTick>()
386 .init_resource::<ItemAssets>()
387 .init_resource::<ColonyLog>()
388 .init_resource::<Reservations>()
389 .add_systems(Update, leave);
390 let spawn = |app: &mut App, mood: f32| {
391 app.world_mut()
392 .spawn((
393 Colonist { name: "Ada".into() },
394 Mood(mood),
395 Unhappy(LEAVE_AFTER_HOURS - 1),
396 ))
397 .id()
398 };
399 let gloomy = spawn(&mut app, LEAVE_MOOD / 2.0);
400 let cheered_up = spawn(&mut app, 0.5);
401 app.update();
402 assert!(app.world().get_entity(gloomy).is_err());
403 assert_eq!(app.world().get::<Unhappy>(cheered_up), Some(&Unhappy(0)));
404 let log = app.world().resource::<ColonyLog>();
405 assert!(log.0[0].ends_with("Ada left the colony, unhappy"));
406 }
407
408 #[test]
409 fn ages_count_days_as_years() {
410 let age = Age {
411 born: -(20 * DAY_TICKS as i64),
412 };
413 assert_eq!(age.years(SimTick(DAY_TICKS / 2)), 20);
414 }
415 }