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