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