src/population.rs 7.7 K · 250 lines · raw · history

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