use serde::{Deserialize, Serialize}; use std::collections::HashMap; use bevy::prelude::*; use crate::{ building::Blueprints, climate::cold_days_ahead, items::ItemKind, sim::{SimSystems, SimTick, TICKS_PER_HOUR}, }; pub struct EconomyPlugin; impl Plugin for EconomyPlugin { fn build(&self, app: &mut App) { app.init_resource::() .add_systems(FixedUpdate, learn_needs.in_set(SimSystems::Decide)); } } pub const COLONY_RADIUS: i32 = 60; const HOURS_OF_MEMORY: f32 = 24.0; const MAX_GOAL: f32 = 300.0; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Stock { Wood, Stone, Iron, Gold, RawFood, Meals, Tools, Medicine, } impl Stock { pub const ALL: [Stock; 8] = [ Stock::Wood, Stock::Stone, Stock::Iron, Stock::Gold, Stock::RawFood, Stock::Meals, Stock::Tools, Stock::Medicine, ]; pub fn of(kind: ItemKind) -> Stock { match kind { ItemKind::Wood => Stock::Wood, ItemKind::Stone => Stock::Stone, ItemKind::Iron => Stock::Iron, ItemKind::Gold => Stock::Gold, ItemKind::Berries | ItemKind::Meat | ItemKind::Vegetables | ItemKind::Carcass => { Stock::RawFood } ItemKind::Meal => Stock::Meals, ItemKind::Tools => Stock::Tools, ItemKind::Herbs => Stock::Medicine, } } pub fn members(self) -> impl Iterator { ItemKind::ALL .into_iter() .filter(move |&k| Stock::of(k) == self) } pub fn is_food(self) -> bool { matches!(self, Stock::RawFood | Stock::Meals) } pub fn label(self) -> &'static str { match self { Stock::Wood => "Wood", Stock::Stone => "Stone", Stock::Iron => "Iron", Stock::Gold => "Gold", Stock::RawFood => "Raw food", Stock::Meals => "Meals", Stock::Tools => "Tools", Stock::Medicine => "Herbs", } } fn buffer_days(self, cold_days: u64) -> f32 { let normal: f32 = if self.is_food() { 1.5 } else { 2.0 }; match self { Stock::Wood | Stock::RawFood | Stock::Meals => normal.max(cold_days as f32), _ => normal, } } fn minimum(self) -> f32 { match self { Stock::Wood => 15.0, Stock::Stone => 5.0, Stock::Iron => 6.0, Stock::Gold => 0.0, Stock::RawFood => 10.0, Stock::Meals => 3.0, Stock::Tools => 1.0, Stock::Medicine => 4.0, } } fn initial_use_per_day(self) -> f32 { match self { Stock::Wood => 20.0, Stock::Stone => 10.0, Stock::Iron => 3.0, Stock::Gold => 0.0, Stock::RawFood => 30.0, Stock::Meals => 3.0, Stock::Tools => 1.0, Stock::Medicine => 1.0, } } } #[derive(Resource, Debug, Clone, Serialize, Deserialize)] pub struct ColonyGoals { goals: HashMap, use_per_day: HashMap, used_this_hour: HashMap, overrides: HashMap, cold_days: u64, } impl Default for ColonyGoals { fn default() -> Self { let mut goals = Self { goals: HashMap::new(), use_per_day: Stock::ALL .iter() .map(|&s| (s, s.initial_use_per_day())) .collect(), used_this_hour: HashMap::new(), overrides: HashMap::new(), cold_days: 0, }; goals.recompute(&HashMap::new()); goals } } impl ColonyGoals { #[cfg(test)] pub fn fixed(goals: &[(ItemKind, u32)]) -> Self { Self { goals: goals.iter().map(|&(k, n)| (Stock::of(k), n)).collect(), use_per_day: HashMap::new(), used_this_hour: HashMap::new(), overrides: HashMap::new(), cold_days: 0, } } pub fn goal(&self, stock: Stock) -> u32 { self.overrides .get(&stock) .or_else(|| self.goals.get(&stock)) .copied() .unwrap_or(0) } pub fn is_overridden(&self, stock: Stock) -> bool { self.overrides.contains_key(&stock) } pub fn adjust(&mut self, stock: Stock, delta: i32) { let goal = (self.goal(stock) as i32 + delta).clamp(0, MAX_GOAL as i32) as u32; self.overrides.insert(stock, goal); } pub fn reset(&mut self, stock: Stock) { self.overrides.remove(&stock); } pub fn use_per_day(&self, stock: Stock) -> f32 { self.use_per_day.get(&stock).copied().unwrap_or(0.0) } pub fn record_use(&mut self, kind: ItemKind, count: u32) { *self.used_this_hour.entry(Stock::of(kind)).or_insert(0) += count; } pub fn wanted( &self, kind: ItemKind, stocks: &HashMap, on_the_way: &HashMap, ) -> bool { let stock = Stock::of(kind); let have: u32 = stock .members() .map(|k| { stocks.get(&k).copied().unwrap_or(0) + on_the_way.get(&k).copied().unwrap_or(0) }) .sum(); have < self.goal(stock) } pub fn set_cold_days(&mut self, days: u64) { self.cold_days = days; } pub fn learn_hour(&mut self, planned: &HashMap) { for stock in Stock::ALL { let used = self.used_this_hour.remove(&stock).unwrap_or(0) as f32; let rate = self.use_per_day.entry(stock).or_insert(0.0); *rate += (used * 24.0 - *rate) / HOURS_OF_MEMORY; } self.recompute(planned); } fn recompute(&mut self, planned: &HashMap) { for stock in Stock::ALL { let learned = (self.use_per_day(stock) * stock.buffer_days(self.cold_days)) .clamp(stock.minimum(), MAX_GOAL) .ceil() as u32; let goal = learned + planned.get(&stock).copied().unwrap_or(0); self.goals.insert(stock, goal); } } } pub fn in_colony(home: UVec2, tile: UVec2) -> bool { (tile.as_ivec2() - home.as_ivec2()).abs().max_element() <= COLONY_RADIUS } pub fn planned_needs(blueprints: &Blueprints) -> HashMap { let mut needs = HashMap::new(); for kind in blueprints.0.values() { let (item, count) = kind.cost(); *needs.entry(Stock::of(item)).or_insert(0) += count; } needs } fn learn_needs(tick: Res, blueprints: Res, mut goals: ResMut) { if tick.0 == 0 || !tick.0.is_multiple_of(TICKS_PER_HOUR) { return; } goals.set_cold_days(cold_days_ahead(*tick)); goals.learn_hour(&planned_needs(&blueprints)); } #[cfg(test)] mod tests { use super::*; use crate::building::BuildKind; fn learn_days(goals: &mut ColonyGoals, kind: ItemKind, per_hour: u32, days: u32) { for _ in 0..days * 24 { goals.record_use(kind, per_hour); goals.learn_hour(&HashMap::new()); } } #[test] fn targets_follow_what_the_colony_actually_uses() { let mut goals = ColonyGoals::default(); learn_days(&mut goals, ItemKind::Meal, 1, 5); let meals = goals.goal(Stock::Meals); assert!((32..=40).contains(&meals), "{meals}"); learn_days(&mut goals, ItemKind::Meal, 0, 5); assert_eq!(goals.goal(Stock::Meals), 3); } #[test] fn players_can_override_learned_targets_and_hand_them_back() { let mut goals = ColonyGoals::default(); let learned = goals.goal(Stock::Wood); goals.adjust(Stock::Wood, 10); assert_eq!(goals.goal(Stock::Wood), learned + 10); learn_days(&mut goals, ItemKind::Wood, 5, 3); assert_eq!(goals.goal(Stock::Wood), learned + 10); goals.adjust(Stock::Iron, -10); assert_eq!(goals.goal(Stock::Iron), 0); goals.reset(Stock::Wood); assert!(!goals.is_overridden(Stock::Wood)); assert!(goals.goal(Stock::Wood) > learned); } // Nothing grows in the cold days ahead and fires burn all day, so food and wood targets cover // those days instead of the usual short buffer. #[test] fn winter_ahead_raises_food_and_wood_targets() { let mut goals = ColonyGoals::default(); learn_days(&mut goals, ItemKind::Berries, 1, 3); let (food, stone) = (goals.goal(Stock::RawFood), goals.goal(Stock::Stone)); goals.set_cold_days(5); goals.learn_hour(&HashMap::new()); assert!(goals.goal(Stock::RawFood) > food * 3); assert_eq!(goals.goal(Stock::Stone), stone); } #[test] fn unused_materials_are_not_stockpiled() { let mut goals = ColonyGoals::default(); learn_days(&mut goals, ItemKind::Wood, 0, 10); assert_eq!(goals.goal(Stock::Wood), 15); assert_eq!(goals.goal(Stock::Gold), 0); } // Raw foods stand in for each other (cooking accepts any), so the colony targets them as one // group and is free to forage, hunt or farm whichever is at hand. #[test] fn raw_foods_share_one_target() { let goals = ColonyGoals::fixed(&[(ItemKind::Berries, 20)]); let stocks = HashMap::from([(ItemKind::Meat, 15), (ItemKind::Vegetables, 4)]); assert!(goals.wanted(ItemKind::Berries, &stocks, &HashMap::new())); let on_the_way = HashMap::from([(ItemKind::Berries, 1)]); assert!(!goals.wanted(ItemKind::Meat, &stocks, &on_the_way)); } #[test] fn planned_construction_raises_material_targets() { let mut blueprints = Blueprints::default(); for x in 0..10 { blueprints.0.insert( UVec2::new(x, 0), BuildKind::Structure(crate::structures::StructureKind::Wall), ); } let mut goals = ColonyGoals::default(); let before = goals.goal(Stock::Stone); goals.learn_hour(&planned_needs(&blueprints)); assert!(goals.goal(Stock::Stone) >= before + 19); } }