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