src/population.rs 9.7 K · 291 lines · raw · history

1 use serde::{Deserialize, Serialize};
2 use std::collections::VecDeque;
3
4 use bevy::prelude::*;
5
6 use crate::{
7 colonist::{Colonist, ColonistAssets, Path, colonist_name, free_name_index, spawn_colonist},
8 economy::in_colony,
9 food::Hunger,
10 items::{Carrying, Item, ItemAssets, ItemQuery, drop_items},
11 jobs::{CurrentJob, Job, Reservations, Task},
12 mood::Losses,
13 pathfinding::find_path,
14 rng::SimRng,
15 sim::{SimSystems, SimTick, TICKS_PER_HOUR, format_clock},
16 structures::{Structure, StructureKind},
17 world::{TilePos, WorldMap},
18 worldgen::WorldGenerated,
19 zones::Zones,
20 };
21
22 pub struct PopulationPlugin;
23
24 impl Plugin for PopulationPlugin {
25 fn build(&self, app: &mut App) {
26 app.init_resource::<ColonyLog>()
27 .add_systems(Update, clear_log.run_if(on_message::<WorldGenerated>))
28 .add_systems(
29 FixedUpdate,
30 (starve, arrivals).chain().in_set(SimSystems::Act),
31 );
32 }
33 }
34
35 pub const STARVATION_TICKS: u32 = 24 * TICKS_PER_HOUR as u32;
36 pub const MAX_COLONISTS: usize = usize::MAX;
37 pub const GROWTH_FOOD_DAYS: f32 = 3.0;
38 pub const ARRIVAL_CHANCE: f32 = 0.6;
39 const ARRIVAL_ATTEMPTS: usize = 10;
40 const ARRIVAL_HOUR_TICK: u64 = 4 * TICKS_PER_HOUR;
41 const DAY_TICKS: u64 = 24 * TICKS_PER_HOUR;
42 const LOG_LENGTH: usize = 4;
43 const EDGE_ATTEMPTS: usize = 100;
44
45 #[derive(Resource, Default, Debug, Serialize, Deserialize)]
46 pub struct ColonyLog(pub VecDeque<String>);
47
48 impl ColonyLog {
49 pub fn push(&mut self, tick: SimTick, message: String) {
50 debug!("{} {message}", format_clock(tick));
51 self.0
52 .push_front(format!("{}: {message}", format_clock(tick)));
53 self.0.truncate(LOG_LENGTH);
54 }
55 }
56
57 fn clear_log(mut log: ResMut<ColonyLog>) {
58 log.0.clear();
59 }
60
61 #[derive(Component, Default, Debug, Clone, Copy, PartialEq)]
62 pub struct Starvation(pub u32);
63
64 #[allow(clippy::too_many_arguments)]
65 fn starve(
66 mut commands: Commands,
67 tick: Res<SimTick>,
68 item_assets: Res<ItemAssets>,
69 mut log: ResMut<ColonyLog>,
70 mut reservations: ResMut<Reservations>,
71 mut losses: ResMut<Losses>,
72 mut items: ItemQuery,
73 mut colonists: Query<(
74 Entity,
75 &Colonist,
76 &Hunger,
77 &TilePos,
78 &Carrying,
79 &mut Starvation,
80 )>,
81 ) {
82 for (entity, colonist, hunger, tile, carrying, mut starvation) in &mut colonists {
83 starvation.0 = if hunger.0 <= 0.0 { starvation.0 + 1 } else { 0 };
84 if starvation.0 < STARVATION_TICKS {
85 continue;
86 }
87 if let Some(load) = carrying.0 {
88 drop_items(&mut commands, &mut items, &item_assets, tile.0, load);
89 }
90 reservations.release_all(entity);
91 commands.entity(entity).despawn();
92 losses.0.push(tick.0);
93 log.push(*tick, format!("{} starved to death", colonist.name));
94 }
95 }
96
97 pub fn food_in_store<'a>(items: impl Iterator<Item = &'a Item>) -> f32 {
98 items
99 .map(|item| item.count as f32 * item.kind.nutrition())
100 .sum()
101 }
102
103 pub fn prosperous(colonists: usize, food: f32, free_beds: usize, wood: u32) -> bool {
104 colonists < MAX_COLONISTS
105 && food >= colonists.max(1) as f32 * GROWTH_FOOD_DAYS
106 && (free_beds > 0 || wood >= StructureKind::Bed.cost().1)
107 }
108
109 pub fn edge_tile(map: &WorldMap, rng: &mut SimRng) -> Option<UVec2> {
110 let (w, h) = (map.size.x as i32, map.size.y as i32);
111 (0..EDGE_ATTEMPTS).find_map(|_| {
112 let along = rng.range_i32(0..w.max(h));
113 let tile = match rng.range_i32(0..4) {
114 0 => IVec2::new(along.min(w - 1), 0),
115 1 => IVec2::new(along.min(w - 1), h - 1),
116 2 => IVec2::new(0, along.min(h - 1)),
117 _ => IVec2::new(w - 1, along.min(h - 1)),
118 };
119 let open = map.move_cost(tile).is_some() && !map.get(tile).is_some_and(|t| t.is_water());
120 open.then(|| tile.as_uvec2())
121 })
122 }
123
124 #[allow(clippy::too_many_arguments)]
125 fn arrivals(
126 mut commands: Commands,
127 tick: Res<SimTick>,
128 map: Res<WorldMap>,
129 zones: Res<Zones>,
130 assets: Res<ColonistAssets>,
131 item_assets: Res<ItemAssets>,
132 mut materials: ResMut<Assets<ColorMaterial>>,
133 mut rng: ResMut<SimRng>,
134 mut log: ResMut<ColonyLog>,
135 items: Query<(&Item, &TilePos)>,
136 structures: Query<&Structure>,
137 colonists: Query<&Colonist>,
138 ) {
139 if tick.0 % DAY_TICKS != ARRIVAL_HOUR_TICK {
140 return;
141 }
142 let count = colonists.iter().count();
143 let beds = structures
144 .iter()
145 .filter(|s| s.kind == StructureKind::Bed)
146 .count();
147 let home = zones.home(map.size / 2);
148 let stored: Vec<&Item> = items
149 .iter()
150 .filter(|(_, pos)| in_colony(home, pos.0))
151 .map(|(item, _)| item)
152 .collect();
153 let wood = stored
154 .iter()
155 .filter(|i| i.kind == crate::items::ItemKind::Wood)
156 .map(|i| i.count)
157 .sum();
158 let food = food_in_store(stored.iter().copied());
159 let lucky = rng.range_f32(0.0..1.0) < ARRIVAL_CHANCE;
160 if !lucky || !prosperous(count, food, beds.saturating_sub(count), wood) {
161 return;
162 }
163 let home = zones.home(map.size / 2);
164 let Some((tile, route)) = (0..ARRIVAL_ATTEMPTS).find_map(|_| {
165 let tile = edge_tile(&map, &mut rng)?;
166 find_path(&map, tile, home).map(|route| (tile, route))
167 }) else {
168 return;
169 };
170 let index = free_name_index(colonists.iter().map(|c| c.name.as_str()));
171 let newcomer = spawn_colonist(
172 &mut commands,
173 &assets,
174 &item_assets,
175 &mut materials,
176 index,
177 tile,
178 );
179 commands.entity(newcomer).insert((
180 CurrentJob(Some(Job {
181 task: Task::MoveTo(home),
182 ordered: false,
183 })),
184 Path(route.into()),
185 ));
186 log.push(
187 *tick,
188 format!("{} has joined the colony", colonist_name(index)),
189 );
190 }
191
192 #[cfg(test)]
193 mod tests {
194 use super::*;
195 use crate::world::Terrain;
196
197 // Growth needs a few days of food in store per colonist. With only a day's worth, plentiful
198 // cooking at family hearths let births boom to 120 colonists, followed by famine.
199 #[test]
200 fn colonies_grow_only_when_they_can_feed_and_house_newcomers() {
201 assert!(prosperous(3, 9.5, 1, 0));
202 assert!(prosperous(3, 9.5, 0, 20));
203 assert!(!prosperous(3, 5.0, 1, 50));
204 assert!(!prosperous(3, 10.0, 0, 5));
205 assert!(!prosperous(MAX_COLONISTS, 100.0, 5, 100));
206 }
207
208 #[test]
209 fn newcomers_arrive_at_the_map_edge() {
210 let map = WorldMap::filled(UVec2::new(30, 20), Terrain::Grass);
211 let mut rng = SimRng::new(4);
212 for _ in 0..20 {
213 let tile = edge_tile(&map, &mut rng).unwrap();
214 assert!(tile.x == 0 || tile.y == 0 || tile.x == 29 || tile.y == 19);
215 }
216 }
217
218 #[test]
219 fn colonists_starve_after_a_day_without_food() {
220 let mut app = App::new();
221 app.init_resource::<SimTick>()
222 .init_resource::<ItemAssets>()
223 .init_resource::<ColonyLog>()
224 .init_resource::<Reservations>()
225 .init_resource::<Losses>()
226 .add_systems(Update, starve);
227 let hungry = app
228 .world_mut()
229 .spawn((
230 Colonist { name: "Ada".into() },
231 Hunger(0.0),
232 TilePos(UVec2::ONE),
233 Carrying(Some(Item::new(crate::items::ItemKind::Wood, 5))),
234 Starvation(STARVATION_TICKS - 2),
235 ))
236 .id();
237 let fed = app
238 .world_mut()
239 .spawn((
240 Colonist {
241 name: "Bram".into(),
242 },
243 Hunger(0.5),
244 TilePos(UVec2::ONE),
245 Carrying(None),
246 Starvation(STARVATION_TICKS - 2),
247 ))
248 .id();
249 app.update();
250 assert!(app.world().get_entity(hungry).is_ok());
251 app.update();
252 assert!(app.world().get_entity(hungry).is_err());
253 assert!(app.world().get_entity(fed).is_ok());
254 assert_eq!(app.world().get::<Starvation>(fed), Some(&Starvation(0)));
255 let log = app.world().resource::<ColonyLog>();
256 assert!(log.0[0].ends_with("Ada starved to death"));
257 let world = app.world_mut();
258 let dropped: Vec<Item> = world.query::<&Item>().iter(world).copied().collect();
259 assert_eq!(dropped.len(), 1);
260 }
261
262 // Newcomers used to get a "walk home" job with no route, so they stood at the map edge until
263 // they starved or a wolf found them. They now arrive with a path to the colony.
264 #[test]
265 fn newcomers_walk_to_the_colony() {
266 use crate::{colonist::Path, items::ItemKind, world::Terrain};
267 let map = WorldMap::filled(UVec2::splat(40), Terrain::Grass);
268 let arrived = (0..20).find_map(|seed| {
269 let mut zones = Zones::default();
270 zones.add_tiles(&map, [UVec2::new(20, 20)]);
271 let mut app = App::new();
272 app.insert_resource(map.clone())
273 .insert_resource(zones)
274 .insert_resource(SimTick(ARRIVAL_HOUR_TICK))
275 .insert_resource(SimRng::new(seed))
276 .init_resource::<ColonistAssets>()
277 .init_resource::<ItemAssets>()
278 .init_resource::<Assets<ColorMaterial>>()
279 .init_resource::<ColonyLog>()
280 .add_systems(Update, arrivals);
281 let world = app.world_mut();
282 world.spawn((Item::new(ItemKind::Wood, 30), TilePos(UVec2::new(20, 20))));
283 world.spawn((Item::new(ItemKind::Meal, 5), TilePos(UVec2::new(21, 20))));
284 app.update();
285 let world = app.world_mut();
286 let mut query = world.query_filtered::<&Path, With<Colonist>>();
287 query.iter(world).next().map(|path| path.0.back().copied())
288 });
289 assert_eq!(arrived, Some(Some(UVec2::new(20, 20))));
290 }
291 }