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