use std::collections::VecDeque; use bevy::prelude::*; use crate::{ colonist::{Colonist, ColonistAssets, NAMES, spawn_colonist}, economy::in_colony, food::Hunger, items::{Carrying, Item, ItemAssets, ItemQuery, drop_items}, jobs::{CurrentJob, Job, Reservations, Task}, mood::Losses, 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 = 12; pub const ARRIVAL_CHANCE: f32 = 0.6; 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)] pub struct ColonyLog(pub VecDeque); impl ColonyLog { pub fn push(&mut self, tick: SimTick, message: String) { 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 && (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 Some(tile) = edge_tile(&map, &mut rng) else { return; }; let taken: Vec<&str> = colonists.iter().map(|c| c.name.as_str()).collect(); let index = (0..NAMES.len()) .find(|&i| !taken.contains(&NAMES[i])) .unwrap_or(count); let newcomer = spawn_colonist( &mut commands, &assets, &item_assets, &mut materials, index, tile, ); commands.entity(newcomer).insert(CurrentJob(Some(Job { task: Task::MoveTo(zones.home(map.size / 2)), ordered: false, }))); log.push( *tick, format!("{} has joined the colony", NAMES[index % NAMES.len()]), ); } #[cfg(test)] mod tests { use super::*; use crate::world::Terrain; #[test] fn colonies_grow_only_when_they_can_feed_and_house_newcomers() { assert!(prosperous(3, 3.5, 1, 0)); assert!(prosperous(3, 3.5, 0, 20)); assert!(!prosperous(3, 2.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); } }