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