src/mood.rs 10.5 K · 386 lines · raw · history

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