Add mood, food poisoning, leaving and old age

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-24 22:23 UTC
commit
2f6e9dbe99d9632d6af090c3860d55fc4f245c6c
parent
8de1aa4f2b
tree
browse at this commit

8 files changed +436 -13

CLAUDE.md +1 -0

@@ -43,6 +43,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
43 43 - `src/economy.rs` learned stock targets. `Stock::{Wood, Stone, Iron, Gold, RawFood, Meals}` groups item kinds (raw foods are one group). `ColonyGoals` keeps `use_per_day` per stock (EMA, ~24 h memory; seeded with guesses), `record_use(kind, n)` is called by jobs for real consumption (eating, cooking input, build material — not spoilage), `learn_needs` (hourly) updates rates and goals = clamp(rate × buffer days (2 materials, 1.5 food), min, 300) + material for pending blueprints (`planned_needs`). `wanted(kind, stocks, on_the_way)` compares the whole group. Stock counts only ground items `in_colony` (Chebyshev ≤ `COLONY_RADIUS` 60 of home; colonist searches cover it, tested) plus carried ones — far stray stacks used to satisfy goals while unreachable (jobs, info panel and arrivals all use this). `ColonyGoals::fixed` for tests.
44 44 - `src/population.rs` `ColonyLog` (last 4 events, shown in the info panel). `starve`: `Starvation` ticks at zero hunger; after STARVATION_TICKS (1 day) the colonist dies (drops load, releases reservations). `arrivals` daily at 10:00: if `prosperous` (≤ MAX_COLONISTS, stored nutrition ≥ 1 per colonist, a free bed or enough wood for one) and a 60% roll, a newcomer (`spawn_colonist`, next unused name) appears at an `edge_tile` with an unordered MoveTo home.
45 45 - `src/family.rs` families. `Family(u32)` (shared by partners and their children), `Partner(Entity)`, `Child{born}` (CHILD_SCALE visual, grows up after CHILDHOOD_TICKS = 4 days in `grow_up`) on colonists. `bond` (hourly): unpartnered adults not of the same family within BOND_RANGE accumulate `Affinity` hours per pair; ≥ BOND_HOURS + BOND_CHANCE roll → partners with a new family id; dangling Partner removed when the partner dies. `Homes{by_family: family → Home{lo, hi}}` (wall-ring corners, interior HOME_INTERIOR 4×3, `bed_tiles` 2 beds top-left, `owner(tile)`). `plan_homes` (every 300 ticks, Decide): drops homes of extinct families; roofs finished homes (`roofs_needed`: interior enclosed exactly); plans one home per partnered family without one via `HomeContext::plan` (BFS from colony home, center ≥ HOME_MIN_DISTANCE, core open + 1-tile belt free of stockpile/farm/blueprints/homes) → wall/door/bed blueprints (door nearest colony home). `births` (daily 20:00): family with 2 living partners, built home (roofed, 2 beds), < MAX_CHILDREN, colony < MAX_COLONISTS and food ≥ colonists, BIRTH_CHANCE → child spawned inside home. Children: choose_jobs gives them only sleep/eat/wander; `find_bed_site` counts adults. Sleep: own home bed (adults), free roofed home floor (children), else communal beds (`Homes::owner` None) → campfire. `plan_shelter` ignores beds inside homes. Selection text shows partner / child days left.
46 +- `src/mood.rs` `Mood(f32)` (default 0.6) eases hourly (1/MOOD_HOURS) toward `mood_target(MoodFactors)`: hunger, rest, last meal cooked/raw within a day, `Sick`, partner, built family home, grief (a `Losses` tick within 2 days; starvation and old age push losses). `Ate{colonist, kind}` message from run_jobs' Eat completion → `digest`: `LastMeal`, raw food rolls `poison_chance` (meat 15%, berries/veg 4%) → `Sick{until}` 12 h (work rate halved in run_jobs). `leave`: adults below LEAVE_MOOD for LEAVE_AFTER_HOURS (48, `Unhappy` hours) leave (despawn, drop load). `Age{born}` (1 day = 1 year; `assign_age` gives new adults 18–40, children 0); `old_age` daily: from OLD_AGE 60, death chance 3%/year over. Selection shows mood/age/sick.
46 47 - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep).
47 48
48 49 ## Gotchas

src/jobs/mod.rs +20 -9

@@ -19,6 +19,7 @@ use crate::{
19 19 stock, take_items,
20 20 },
21 21 light::{LIT_BRIGHTNESS, LightMap, brightness, dark_soon, light_from, line_of_sight},
22 + mood::{Ate, Sick},
22 23 needs::{EXHAUSTED_REST, Rest, SleepContext, done_sleeping, wants_sleep},
23 24 pathfinding::{find_path, find_path_adjacent, is_adjacent, nearest_of, nearest_tile},
24 25 rng::SimRng,
@@ -1122,7 +1123,7 @@ fn run_jobs(
1122 1123 mut items: ItemQuery,
1123 1124 mut structures: Query<(Entity, &TilePos, &Structure, Option<&mut Fuel>), Without<Colonist>>,
1124 1125 animals: Query<(&TilePos, &Path), (With<Animal>, Without<Colonist>)>,
1125 - mut changed: MessageWriter<TileChanged>,
1126 + messages: (MessageWriter<TileChanged>, MessageWriter<Ate>),
1126 1127 mut colonists: Query<
1127 1128 (
1128 1129 Entity,
@@ -1133,10 +1134,12 @@ fn run_jobs(
1133 1134 &mut Carrying,
1134 1135 &Rest,
1135 1136 &mut Hunger,
1137 + Has<Sick>,
1136 1138 ),
1137 1139 With<Colonist>,
1138 1140 >,
1139 1141 ) {
1142 + let (mut changed, mut ate) = messages;
1140 1143 let hour = hour_of_day(*tick);
1141 1144 let occupied: Vec<UVec2> = colonists
1142 1145 .iter()
@@ -1148,12 +1151,16 @@ fn run_jobs(
1148 1151 )
1149 1152 .flatten()
1150 1153 .collect();
1151 - for (entity, tile, mut path, mut current, mut idle, mut carrying, rest, mut hunger) in
1154 + for (entity, tile, mut path, mut current, mut idle, mut carrying, rest, mut hunger, sick) in
1152 1155 &mut colonists
1153 1156 {
1154 1157 let Some(mut job) = current.0 else {
1155 1158 continue;
1156 1159 };
1160 + let rate = match work_rate(&light, tile.0) {
1161 + rate if sick => rate.div_ceil(2),
1162 + rate => rate,
1163 + };
1157 1164 if !path.0.is_empty() {
1158 1165 continue;
1159 1166 }
@@ -1203,7 +1210,7 @@ fn run_jobs(
1203 1210 _ => Step::Done,
1204 1211 }
1205 1212 } else {
1206 - *progress += work_rate(&light, tile.0);
1213 + *progress += rate;
1207 1214 if *progress < COOK_WORK {
1208 1215 Step::Continue
1209 1216 } else {
@@ -1251,7 +1258,7 @@ fn run_jobs(
1251 1258 }
1252 1259 } else {
1253 1260 path.0.truncate(1);
1254 - *progress += work_rate(&light, tile.0);
1261 + *progress += rate;
1255 1262 if *progress < kind.hunt_work() {
1256 1263 Step::Continue
1257 1264 } else {
@@ -1289,7 +1296,7 @@ fn run_jobs(
1289 1296 Step::Continue
1290 1297 }
1291 1298 } else {
1292 - *progress += work_rate(&light, tile.0);
1299 + *progress += rate;
1293 1300 if *progress < kind.tame_work() {
1294 1301 Step::Continue
1295 1302 } else {
@@ -1307,7 +1314,7 @@ fn run_jobs(
1307 1314 } else if tile.0 != site {
1308 1315 walk_to(&map, tile.0, site, &mut path)
1309 1316 } else {
1310 - *progress += work_rate(&light, tile.0);
1317 + *progress += rate;
1311 1318 if *progress < BUTCHER_WORK {
1312 1319 Step::Continue
1313 1320 } else {
@@ -1359,6 +1366,10 @@ fn run_jobs(
1359 1366 } else {
1360 1367 hunger.0 = (hunger.0 + meal.count as f32 * meal.kind.nutrition()).min(1.0);
1361 1368 goals.record_use(meal.kind, meal.count);
1369 + ate.write(Ate {
1370 + colonist: entity,
1371 + kind: meal.kind,
1372 + });
1362 1373 carrying.0 = None;
1363 1374 Step::Done
1364 1375 }
@@ -1404,7 +1415,7 @@ fn run_jobs(
1404 1415 _ => Step::Done,
1405 1416 }
1406 1417 } else {
1407 - *progress += work_rate(&light, tile.0);
1418 + *progress += rate;
1408 1419 if *progress < kind.work() {
1409 1420 Step::Continue
1410 1421 } else {
@@ -1427,7 +1438,7 @@ fn run_jobs(
1427 1438 if tile.0 != *site {
1428 1439 walk_to(&map, tile.0, *site, &mut path)
1429 1440 } else {
1430 - *progress += work_rate(&light, tile.0);
1441 + *progress += rate;
1431 1442 if *progress >= EXPAND_WORK {
1432 1443 if zones.has_stockpile() {
1433 1444 let excluded = blueprints.tiles();
@@ -1519,7 +1530,7 @@ fn run_jobs(
1519 1530 _ => Step::Done,
1520 1531 }
1521 1532 } else {
1522 - *progress = (*progress + work_rate(&light, tile.0)).min(kind.work());
1533 + *progress = (*progress + rate).min(kind.work());
1523 1534 let someone_in_the_way = kind.blocks_tile() && occupied.contains(&site);
1524 1535 if *progress < kind.work() || someone_in_the_way {
1525 1536 Step::Continue

src/jobs/tests.rs +1 -0

@@ -21,6 +21,7 @@ fn test_app(map: WorldMap) -> App {
21 21 .init_resource::<Time>()
22 22 .init_resource::<Reservations>()
23 23 .init_resource::<crate::family::Homes>()
24 + .add_message::<crate::mood::Ate>()
24 25 .init_resource::<crate::designations::Designations>()
25 26 .init_resource::<ColonyGoals>()
26 27 .init_resource::<ItemAssets>()

src/main.rs +2 -0

@@ -11,6 +11,7 @@ mod food;
11 11 mod items;
12 12 mod jobs;
13 13 mod light;
14 +mod mood;
14 15 mod needs;
15 16 mod noise;
16 17 mod pathfinding;
@@ -68,6 +69,7 @@ fn main() {
68 69 population::PopulationPlugin,
69 70 family::FamilyPlugin,
70 71 designations::DesignationsPlugin,
72 + mood::MoodPlugin,
71 73 plants::PlantsPlugin,
72 74 selection::SelectionPlugin,
73 75 zones::ZonesPlugin,

src/mood.rs +386 -0

@@ -0,0 +1,386 @@
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 +}

src/population.rs +4 -0

@@ -8,6 +8,7 @@ use crate::{
8 8 food::Hunger,
9 9 items::{Carrying, Item, ItemAssets, ItemQuery, drop_items},
10 10 jobs::{CurrentJob, Job, Reservations, Task},
11 + mood::Losses,
11 12 rng::SimRng,
12 13 sim::{SimSystems, SimTick, TICKS_PER_HOUR, format_clock},
13 14 structures::{Structure, StructureKind},
@@ -62,6 +63,7 @@ fn starve(
62 63 item_assets: Res<ItemAssets>,
63 64 mut log: ResMut<ColonyLog>,
64 65 mut reservations: ResMut<Reservations>,
66 + mut losses: ResMut<Losses>,
65 67 mut items: ItemQuery,
66 68 mut colonists: Query<(
67 69 Entity,
@@ -82,6 +84,7 @@ fn starve(
82 84 }
83 85 reservations.release_all(entity);
84 86 commands.entity(entity).despawn();
87 + losses.0.push(tick.0);
85 88 log.push(*tick, format!("{} starved to death", colonist.name));
86 89 }
87 90 }
@@ -208,6 +211,7 @@ mod tests {
208 211 .init_resource::<ItemAssets>()
209 212 .init_resource::<ColonyLog>()
210 213 .init_resource::<Reservations>()
214 + .init_resource::<Losses>()
211 215 .add_systems(Update, starve);
212 216 let hungry = app
213 217 .world_mut()

src/selection.rs +21 -3

@@ -13,6 +13,7 @@ use crate::{
13 13 items::{Carrying, Item, stock},
14 14 jobs::{BUTCHER_WORK, COOK_WORK, CurrentJob, EXPAND_WORK, Job, Order, Task, WorkKind},
15 15 light::daylight,
16 + mood::{Age, Mood, Sick},
16 17 needs::Rest,
17 18 population::ColonyLog,
18 19 sim::{SimSpeed, SimTick, TICKS_PER_HOUR, format_clock, hour_of_day},
@@ -270,7 +271,13 @@ fn update_tile_info(
270 271 Res<SimSpeed>,
271 272 Res<WorldSeed>,
272 273 ),
273 - kin: Query<(Option<&Partner>, Option<&Child>)>,
274 + kin: Query<(
275 + Option<&Partner>,
276 + Option<&Child>,
277 + Option<&Mood>,
278 + Option<&Age>,
279 + Has<Sick>,
280 + )>,
274 281 mut text: Single<&mut Text, With<TileInfoText>>,
275 282 ) {
276 283 let (time, tick, speed, seed) = clock;
@@ -290,13 +297,24 @@ fn update_tile_info(
290 297 fed: hunger.0,
291 298 }) + &kin
292 299 .get(entity)
293 - .map(|(partner, child)| {
300 + .map(|(partner, child, mood, age, sick)| {
294 301 let partner = partner
295 302 .and_then(|p| colonists.get(p.0).ok())
296 303 .map(|p| format!(", partner {}", p.0.name));
297 304 let child =
298 305 child.map(|c| format!(", child (grows up in {}d)", c.days_left(*tick)));
299 - partner.unwrap_or_default() + &child.unwrap_or_default()
306 + let mood = mood
307 + .map(|m| format!(", mood {:.0}%", m.0 * 100.0))
308 + .unwrap_or_default();
309 + let age = age
310 + .map(|a| format!(", age {}", a.years(*tick)))
311 + .unwrap_or_default();
312 + let sick = if sick { ", sick" } else { "" };
313 + partner.unwrap_or_default()
314 + + &child.unwrap_or_default()
315 + + &mood
316 + + &age
317 + + sick
300 318 })
301 319 .unwrap_or_default()
302 320 })

tasks.md +1 -1

@@ -31,10 +31,10 @@
31 31 - [x] Designations: Chop/Mine tool (X) marks trees and rock for anyone to work
32 32 - [x] Walls cast shadows, roofs keep daylight out; deconstruction (build tool mode, refunds half)
33 33 - [x] Animals flee, ranged hunting, carcasses butchered into meat, taming with breeding herds and slaughter of spare tame animals
34 +- [x] Mood (needs, meals, partner, home, sickness, grief), food poisoning from raw food, unhappy colonists leave, ageing and death of old age
34 35 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
35 36
36 37 ## Planned
37 -- [ ] Mood, food poisoning from raw food; colonists leaving when unhappy; old age/disease
38 38 - [ ] Per-colonist work priorities (busy colonies leave low-priority work such as hauling undone)
39 39 - [ ] Stockpile filters (which item kinds a zone accepts) and storage priority
40 40 - [ ] UI to view/override learned stock targets