Add mood, food poisoning, leaving and old age
- 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 | |
| src/jobs/mod.rs | +20 -9 | |
| src/jobs/tests.rs | +1 -0 | |
| src/main.rs | +2 -0 | |
| src/mood.rs | added | +386 -0 |
| src/population.rs | +4 -0 | |
| src/selection.rs | +21 -3 | |
| tasks.md | +1 -1 |
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 |