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