use serde::{Deserialize, Serialize}; use std::collections::VecDeque; use bevy::prelude::*; use crate::{ colonist::{Colonist, ColonistAssets, Path, colonist_name, free_name_index, spawn_colonist}, economy::in_colony, food::Hunger, items::{Carrying, Item, ItemAssets, ItemQuery, drop_items}, jobs::{CurrentJob, Job, Reservations, Task}, mood::Losses, pathfinding::find_path, rng::SimRng, sim::{SimSystems, SimTick, TICKS_PER_HOUR, format_clock}, structures::{Structure, StructureKind}, world::{TilePos, WorldMap}, worldgen::WorldGenerated, zones::Zones, }; pub struct PopulationPlugin; impl Plugin for PopulationPlugin { fn build(&self, app: &mut App) { app.init_resource::() .add_systems(Update, clear_log.run_if(on_message::)) .add_systems( FixedUpdate, (starve, arrivals).chain().in_set(SimSystems::Act), ); } } pub const STARVATION_TICKS: u32 = 24 * TICKS_PER_HOUR as u32; pub const MAX_COLONISTS: usize = usize::MAX; pub const GROWTH_FOOD_DAYS: f32 = 3.0; pub const ARRIVAL_CHANCE: f32 = 0.6; const ARRIVAL_ATTEMPTS: usize = 10; const ARRIVAL_HOUR_TICK: u64 = 4 * TICKS_PER_HOUR; const DAY_TICKS: u64 = 24 * TICKS_PER_HOUR; const LOG_LENGTH: usize = 4; const EDGE_ATTEMPTS: usize = 100; #[derive(Resource, Default, Debug, Serialize, Deserialize)] pub struct ColonyLog(pub VecDeque); impl ColonyLog { pub fn push(&mut self, tick: SimTick, message: String) { debug!("{} {message}", format_clock(tick)); self.0 .push_front(format!("{}: {message}", format_clock(tick))); self.0.truncate(LOG_LENGTH); } } fn clear_log(mut log: ResMut) { log.0.clear(); } #[derive(Component, Default, Debug, Clone, Copy, PartialEq)] pub struct Starvation(pub u32); #[allow(clippy::too_many_arguments)] fn starve( mut commands: Commands, tick: Res, item_assets: Res, mut log: ResMut, mut reservations: ResMut, mut losses: ResMut, mut items: ItemQuery, mut colonists: Query<( Entity, &Colonist, &Hunger, &TilePos, &Carrying, &mut Starvation, )>, ) { for (entity, colonist, hunger, tile, carrying, mut starvation) in &mut colonists { starvation.0 = if hunger.0 <= 0.0 { starvation.0 + 1 } else { 0 }; if starvation.0 < STARVATION_TICKS { continue; } if let Some(load) = carrying.0 { drop_items(&mut commands, &mut items, &item_assets, tile.0, load); } reservations.release_all(entity); commands.entity(entity).despawn(); losses.0.push(tick.0); log.push(*tick, format!("{} starved to death", colonist.name)); } } pub fn food_in_store<'a>(items: impl Iterator) -> f32 { items .map(|item| item.count as f32 * item.kind.nutrition()) .sum() } pub fn prosperous(colonists: usize, food: f32, free_beds: usize, wood: u32) -> bool { colonists < MAX_COLONISTS && food >= colonists.max(1) as f32 * GROWTH_FOOD_DAYS && (free_beds > 0 || wood >= StructureKind::Bed.cost().1) } pub fn edge_tile(map: &WorldMap, rng: &mut SimRng) -> Option { let (w, h) = (map.size.x as i32, map.size.y as i32); (0..EDGE_ATTEMPTS).find_map(|_| { let along = rng.range_i32(0..w.max(h)); let tile = match rng.range_i32(0..4) { 0 => IVec2::new(along.min(w - 1), 0), 1 => IVec2::new(along.min(w - 1), h - 1), 2 => IVec2::new(0, along.min(h - 1)), _ => IVec2::new(w - 1, along.min(h - 1)), }; let open = map.move_cost(tile).is_some() && !map.get(tile).is_some_and(|t| t.is_water()); open.then(|| tile.as_uvec2()) }) } #[allow(clippy::too_many_arguments)] fn arrivals( mut commands: Commands, tick: Res, map: Res, zones: Res, assets: Res, item_assets: Res, mut materials: ResMut>, mut rng: ResMut, mut log: ResMut, items: Query<(&Item, &TilePos)>, structures: Query<&Structure>, colonists: Query<&Colonist>, ) { if tick.0 % DAY_TICKS != ARRIVAL_HOUR_TICK { return; } let count = colonists.iter().count(); let beds = structures .iter() .filter(|s| s.kind == StructureKind::Bed) .count(); let home = zones.home(map.size / 2); let stored: Vec<&Item> = items .iter() .filter(|(_, pos)| in_colony(home, pos.0)) .map(|(item, _)| item) .collect(); let wood = stored .iter() .filter(|i| i.kind == crate::items::ItemKind::Wood) .map(|i| i.count) .sum(); let food = food_in_store(stored.iter().copied()); let lucky = rng.range_f32(0.0..1.0) < ARRIVAL_CHANCE; if !lucky || !prosperous(count, food, beds.saturating_sub(count), wood) { return; } let home = zones.home(map.size / 2); let Some((tile, route)) = (0..ARRIVAL_ATTEMPTS).find_map(|_| { let tile = edge_tile(&map, &mut rng)?; find_path(&map, tile, home).map(|route| (tile, route)) }) else { return; }; let index = free_name_index(colonists.iter().map(|c| c.name.as_str())); let newcomer = spawn_colonist( &mut commands, &assets, &item_assets, &mut materials, index, tile, ); commands.entity(newcomer).insert(( CurrentJob(Some(Job { task: Task::MoveTo(home), ordered: false, })), Path(route.into()), )); log.push( *tick, format!("{} has joined the colony", colonist_name(index)), ); } #[cfg(test)] mod tests { use super::*; use crate::world::Terrain; // Growth needs a few days of food in store per colonist. With only a day's worth, plentiful // cooking at family hearths let births boom to 120 colonists, followed by famine. #[test] fn colonies_grow_only_when_they_can_feed_and_house_newcomers() { assert!(prosperous(3, 9.5, 1, 0)); assert!(prosperous(3, 9.5, 0, 20)); assert!(!prosperous(3, 5.0, 1, 50)); assert!(!prosperous(3, 10.0, 0, 5)); assert!(!prosperous(MAX_COLONISTS, 100.0, 5, 100)); } #[test] fn newcomers_arrive_at_the_map_edge() { let map = WorldMap::filled(UVec2::new(30, 20), Terrain::Grass); let mut rng = SimRng::new(4); for _ in 0..20 { let tile = edge_tile(&map, &mut rng).unwrap(); assert!(tile.x == 0 || tile.y == 0 || tile.x == 29 || tile.y == 19); } } #[test] fn colonists_starve_after_a_day_without_food() { let mut app = App::new(); app.init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .add_systems(Update, starve); let hungry = app .world_mut() .spawn(( Colonist { name: "Ada".into() }, Hunger(0.0), TilePos(UVec2::ONE), Carrying(Some(Item::new(crate::items::ItemKind::Wood, 5))), Starvation(STARVATION_TICKS - 2), )) .id(); let fed = app .world_mut() .spawn(( Colonist { name: "Bram".into(), }, Hunger(0.5), TilePos(UVec2::ONE), Carrying(None), Starvation(STARVATION_TICKS - 2), )) .id(); app.update(); assert!(app.world().get_entity(hungry).is_ok()); app.update(); assert!(app.world().get_entity(hungry).is_err()); assert!(app.world().get_entity(fed).is_ok()); assert_eq!(app.world().get::(fed), Some(&Starvation(0))); let log = app.world().resource::(); assert!(log.0[0].ends_with("Ada starved to death")); let world = app.world_mut(); let dropped: Vec = world.query::<&Item>().iter(world).copied().collect(); assert_eq!(dropped.len(), 1); } // Newcomers used to get a "walk home" job with no route, so they stood at the map edge until // they starved or a wolf found them. They now arrive with a path to the colony. #[test] fn newcomers_walk_to_the_colony() { use crate::{colonist::Path, items::ItemKind, world::Terrain}; let map = WorldMap::filled(UVec2::splat(40), Terrain::Grass); let arrived = (0..20).find_map(|seed| { let mut zones = Zones::default(); zones.add_tiles(&map, [UVec2::new(20, 20)]); let mut app = App::new(); app.insert_resource(map.clone()) .insert_resource(zones) .insert_resource(SimTick(ARRIVAL_HOUR_TICK)) .insert_resource(SimRng::new(seed)) .init_resource::() .init_resource::() .init_resource::>() .init_resource::() .add_systems(Update, arrivals); let world = app.world_mut(); world.spawn((Item::new(ItemKind::Wood, 30), TilePos(UVec2::new(20, 20)))); world.spawn((Item::new(ItemKind::Meal, 5), TilePos(UVec2::new(21, 20)))); app.update(); let world = app.world_mut(); let mut query = world.query_filtered::<&Path, With>(); query.iter(world).next().map(|path| path.0.back().copied()) }); assert_eq!(arrived, Some(Some(UVec2::new(20, 20)))); } }