src/mood.rs 10.7 K · 394 lines · raw · history

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